diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml index 500c271..487d4c0 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -11,17 +11,9 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@v4 - with: - command_token_url: ${{ vars.COMMAND_TOKEN_URL }} - command_hostname: ${{ vars.COMMAND_HOSTNAME }} - command_base_api_path: ${{ vars.COMMAND_API_PATH }} + uses: keyfactor/actions/.github/workflows/starter.yml@v5 secrets: token: ${{ secrets.V2BUILDTOKEN }} gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} scan_token: ${{ secrets.SAST_TOKEN }} - entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }} - entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }} - command_client_id: ${{ secrets.COMMAND_CLIENT_ID }} - command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }} diff --git a/.gitignore b/.gitignore index f920fa6..609bcd8 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ terraform/terraform.tfvars # macOS .DS_Store + +# Analysis / scratch — never commit +analysis/ + diff --git a/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs b/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs new file mode 100644 index 0000000..2a8cb2b --- /dev/null +++ b/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs @@ -0,0 +1,181 @@ +// Copyright 2024 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 + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Pkcs; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Key-algorithm coverage matrix: RSA 2048/3072/4096/6144/8192, ECDSA P-256/P-384/P-521, + /// Ed25519, and Ed448 (see ). + /// + /// Motivation: every other test in the suite hardcoded an RSA-2048 CSR, so only RSA-2048 + /// certificates were ever exercised end-to-end (and that is all that showed up in Command). + /// The plugin takes the CSR as enrollment input and submits it verbatim, so the key + /// algorithm is entirely determined by the CSR. + /// + /// This file is the offline / submission-only layer (no DCV, no issuance): + /// 1. — deterministic, no API, always runs. Proves we + /// emit a structurally valid, self-consistent PKCS#10 CSR for each algorithm (the public key + /// type/size round-trips and the request signature verifies). + /// 2. — opt-in (creates real sandbox orders). Proves + /// whether CERTInext *accepts* each algorithm at order submission. A CA-side rejection is + /// reported as an explicit Skip carrying the CA's own message. + /// + /// The end-to-end "does CERTInext actually issue this algorithm" matrix (DCV on, one real + /// scrup.org cert per type) lives in DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm + /// and only exists on the DCV build. + /// + public class AlgorithmMatrixTests : IClassFixture + { + /// Set CERTINEXT_ALGO_MATRIX=1 to run the live submission theory (creates real orders). + private const string OptInFlag = "CERTINEXT_ALGO_MATRIX"; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + public AlgorithmMatrixTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + public static IEnumerable KeyTypes => KeyAlgorithms.AsMemberData; + + // --------------------------------------------------------------------------- + // Layer 1 — deterministic CSR-validity round-trip (no API, always runs) + // --------------------------------------------------------------------------- + + /// + /// Generates a CSR for the given key type, re-parses it, and asserts the public key + /// algorithm/size round-trips and the request signature verifies. Fully offline. + /// + /// Note: RSA-6144 and RSA-8192 key generation is intentionally slow (seconds to tens of + /// seconds) — that cost is inherent to large RSA keygen, not the test. + /// + [Theory] + [MemberData(nameof(KeyTypes))] + public void Csr_RoundTripsKeyAlgorithm(string tag) + { + var spec = KeyAlgorithms.For(tag); + + string pem = KeyAlgorithms.GenerateCsrPem($"algo-{KeyAlgorithms.Slug(tag)}.example.com", spec); + + var request = new Pkcs10CertificationRequest(KeyAlgorithms.DerFromPem(pem)); + + request.Verify().Should().BeTrue($"the {tag} CSR must be self-signed with a verifiable signature"); + + var pub = request.GetPublicKey(); + + switch (spec.Kind) + { + case KeyKind.Rsa: + pub.Should().BeOfType(); + // BouncyCastle generates a modulus of exactly 'Strength' bits (top bit set). + ((RsaKeyParameters)pub).Modulus.BitLength.Should().Be(spec.Strength, + $"the RSA modulus must be {spec.Strength} bits"); + break; + + case KeyKind.Ecdsa: + pub.Should().BeOfType(); + ((ECPublicKeyParameters)pub).Parameters.Curve.FieldSize.Should().Be(spec.Strength, + $"the EC field size must be {spec.Strength} bits"); + break; + + case KeyKind.Ed25519: + pub.Should().BeOfType(); + break; + + case KeyKind.Ed448: + pub.Should().BeOfType(); + break; + } + + _output.WriteLine($"[OK] {tag}: CSR generated ({pem.Length} chars PEM), signature verified, public key type confirmed."); + } + + // --------------------------------------------------------------------------- + // Layer 2 — live submission acceptance (opt-in; creates real sandbox orders) + // --------------------------------------------------------------------------- + + /// + /// Submits a real order to CERTInext for each key type and asserts the order is accepted + /// (a CARequestID is returned). A CA-side rejection is reported as an explicit Skip carrying + /// the CA's own error message — so the suite documents which algorithms CERTInext accepts + /// rather than failing on a legitimate CA limitation. + /// + /// Opt-in: requires CERTINEXT_ALGO_MATRIX=1 because each run creates a real (pending, + /// non-issued) DV order on the sandbox account. No DCV is performed, so the orders park at + /// EXTERNALVALIDATION and are not cleaned up here. "Accepted at submission" is weaker than + /// "will issue" — see DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm for the + /// end-to-end issuance matrix. + /// + [SkippableTheory] + [MemberData(nameof(KeyTypes))] + public async Task Enroll_AcceptsKeyAlgorithm(string tag) + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.IfNot( + Environment.GetEnvironmentVariable(OptInFlag) == "1", + $"Set {OptInFlag}=1 to run the live algorithm-submission matrix (creates real sandbox orders)."); + + var spec = KeyAlgorithms.For(tag); + string cn = $"algo-{KeyAlgorithms.Slug(tag)}.example.com"; + string csrPem = KeyAlgorithms.GenerateCsrPem(cn, spec); + + var productInfo = new EnrollmentProductInfo + { + ProductID = _fixture.ProductCode, + ProductParameters = new Dictionary + { + [Constants.EnrollmentParam.ProfileId] = _fixture.ProductCode, + [Constants.EnrollmentParam.ProductCode] = _fixture.ProductCode, + [Constants.EnrollmentParam.RequesterName] = _fixture.RequestorName, + [Constants.EnrollmentParam.RequesterEmail] = _fixture.RequestorEmail, + } + }; + + var sanDict = new Dictionary { ["DNS"] = new[] { cn } }; + + var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config); + + EnrollmentResult enrollResult = null; + try + { + enrollResult = await plugin.Enroll( + csrPem, + $"CN={cn}", + sanDict, + productInfo, + RequestFormat.PKCS10, + EnrollmentType.New); + } + catch (Exception ex) + { + // Per agreed scope: a CA-side rejection becomes an explicit Skip carrying the CA's + // message (classified so an unsupported algorithm isn't confused with a credit/ + // account limitation), so the matrix documents real CERTInext support honestly. + string reason = KeyAlgorithms.ClassifyRejection(ex.Message); + _output.WriteLine($"[SKIP] {tag}: {reason} — {ex.Message}"); + Skip.If(true, $"CERTInext did not accept a {tag} order: {reason}. CA message: {ex.Message}"); + } + + enrollResult.Should().NotBeNull($"{tag}: Enroll must return a non-null result when accepted"); + if (enrollResult == null) return; // satisfies nullable analysis; assertion above already failed + + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace( + $"{tag}: a CARequestID must be returned when CERTInext accepts the order"); + + _output.WriteLine($"[OK] {tag}: CERTInext accepted the order. CARequestID={enrollResult.CARequestID}"); + } + } +} diff --git a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj index 91e6472..bd3ec73 100644 --- a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj +++ b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj @@ -6,12 +6,26 @@ 12.0 false true + + false + $(DefineConstants);SUPPORTS_DCV + + + + + + + @@ -21,6 +35,7 @@ + diff --git a/CERTInext.IntegrationTests/CloudflareDomainValidator.cs b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs new file mode 100644 index 0000000..89c01eb --- /dev/null +++ b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs @@ -0,0 +1,129 @@ +// Copyright 2024 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 + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// that publishes and removes DNS TXT records via + /// the Cloudflare v4 API. Intended for integration tests against a real domain. + /// + /// Credentials are read from the : + /// CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID. + /// + internal sealed class CloudflareDomainValidator : IDomainValidator + { + private const string CfApiBase = "https://api.cloudflare.com/client/v4"; + + private readonly string _apiToken; + private readonly string _zoneId; + private readonly HttpClient _http; + + // Maps staging hostname → Cloudflare record ID so CleanupValidation can delete it + private readonly ConcurrentDictionary _stagedRecordIds = new(); + + public CloudflareDomainValidator(string apiToken, string zoneId) + { + _apiToken = apiToken ?? throw new ArgumentNullException(nameof(apiToken)); + _zoneId = zoneId ?? throw new ArgumentNullException(nameof(zoneId)); + + _http = new HttpClient(); + _http.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", _apiToken); + } + + public void Initialize(IDomainValidatorConfigProvider configProvider) { } + + public async Task StageValidation(string key, string value, CancellationToken cancellationToken) + { + var payload = new + { + type = "TXT", + name = key, + content = value, + ttl = 60 + }; + + var response = await _http.PostAsJsonAsync( + $"{CfApiBase}/zones/{_zoneId}/dns_records", + payload, + cancellationToken); + + string body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + return new DomainValidationResult + { + Success = false, + ErrorMessage = $"Cloudflare API error {(int)response.StatusCode}: {body}" + }; + + using var doc = JsonDocument.Parse(body); + bool success = doc.RootElement.GetProperty("success").GetBoolean(); + string recordId = success + ? doc.RootElement.GetProperty("result").GetProperty("id").GetString() + : null; + + if (!success || string.IsNullOrEmpty(recordId)) + return new DomainValidationResult + { + Success = false, + ErrorMessage = $"Cloudflare record creation failed: {body}" + }; + + _stagedRecordIds[key] = recordId; + + return new DomainValidationResult { Success = true }; + } + + public async Task CleanupValidation(string key, CancellationToken cancellationToken) + { + if (!_stagedRecordIds.TryRemove(key, out string recordId)) + return new DomainValidationResult { Success = true }; // nothing to clean up + + var response = await _http.DeleteAsync( + $"{CfApiBase}/zones/{_zoneId}/dns_records/{recordId}", + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + string body = await response.Content.ReadAsStringAsync(cancellationToken); + return new DomainValidationResult + { + Success = false, + ErrorMessage = $"Cloudflare delete error {(int)response.StatusCode}: {body}" + }; + } + + return new DomainValidationResult { Success = true }; + } + + public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask; + public Dictionary GetDomainValidatorAnnotations() => new(); + public string GetValidationType() => "dns-01"; + } + + internal sealed class CloudflareDomainValidatorFactory : IDomainValidatorFactory + { + private readonly IDomainValidator _validator; + + public CloudflareDomainValidatorFactory(string apiToken, string zoneId) + { + _validator = new CloudflareDomainValidator(apiToken, zoneId); + } + + public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator; + } +} diff --git a/CERTInext.IntegrationTests/DcvLifecycleTests.cs b/CERTInext.IntegrationTests/DcvLifecycleTests.cs new file mode 100644 index 0000000..24ba0f1 --- /dev/null +++ b/CERTInext.IntegrationTests/DcvLifecycleTests.cs @@ -0,0 +1,871 @@ +// Copyright 2024 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 + +using System; +using System.Collections.Generic; +using System.Linq; +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.PKI.Enums.EJBCA; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Integration tests for the DNS DCV enrollment path. + /// + /// DNS validator selection: + /// • When CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID are set in + /// ~/.env_certinext, a is used and + /// a real TXT record is published and cleaned up around the enrollment. + /// • Otherwise a is used. The plugin still + /// exercises the full DCV orchestration path (Stage → propagation wait → VerifyDcv + /// → Cleanup), but no real DNS record is published. Whether CERTInext's VerifyDcv + /// succeeds in this mode depends on the sandbox environment. + /// + /// All tests skip when CERTInext credentials are absent (). + /// Add the following to ~/.env_certinext to run with real DNS: + /// + /// CERTINEXT_CF_API_TOKEN=<your Cloudflare API token with DNS:Edit> + /// CERTINEXT_CF_ZONE_ID=<Cloudflare Zone ID for your test domain> + /// CERTINEXT_DCV_DOMAIN=<subdomain to use, e.g. dcv-test.example.com> + /// + /// + public class DcvLifecycleTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + public DcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + // --------------------------------------------------------------------------- + // 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 BuildDnsFactory() => + _fixture.IsCloudflareConfigured + ? (IDomainValidatorFactory)new CloudflareDomainValidatorFactory( + _fixture.CloudflareApiToken, _fixture.CloudflareZoneId) + : new StubDomainValidatorFactory(); + + /// + /// Runs plugin.Synchronize and returns every record that came out of the + /// blocking buffer. Mirrors the helper in LifecycleTests; kept local so + /// the DCV bulk test isn't coupled to that file's private member. + /// + private static async Task> RunSyncAsync(CERTInextCAPlugin plugin) + { + var buffer = new System.Collections.Concurrent.BlockingCollection(boundedCapacity: 10_000); + var collected = new List(); + + var syncTask = Task.Run(async () => + { + await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: System.Threading.CancellationToken.None); + if (!buffer.IsAddingCompleted) + buffer.CompleteAdding(); + }); + + foreach (var record in buffer.GetConsumingEnumerable()) + collected.Add(record); + + await syncTask; + return collected; + } + + private CERTInextCAPlugin BuildPlugin(bool dcvEnabled, int propagationDelaySeconds = 5, int? pageSize = null) + { + var config = new CERTInextConfig + { + ApiUrl = _fixture.Config.ApiUrl, + AuthMode = _fixture.Config.AuthMode, + ApiKey = _fixture.Config.ApiKey, + AccountNumber = _fixture.Config.AccountNumber, + GroupNumber = _fixture.Config.GroupNumber, + OrganizationNumber = _fixture.Config.OrganizationNumber, + RequestorName = _fixture.Config.RequestorName, + RequestorEmail = _fixture.Config.RequestorEmail, + RequestorIsdCode = _fixture.Config.RequestorIsdCode, + RequestorMobileNumber = _fixture.Config.RequestorMobileNumber, + SignerPlace = _fixture.Config.SignerPlace, + SignerIp = _fixture.Config.SignerIp, + DefaultProductCode = _fixture.Config.DefaultProductCode, + PageSize = pageSize ?? _fixture.Config.PageSize, + DcvEnabled = dcvEnabled, + DcvPropagationDelaySeconds = propagationDelaySeconds, + DcvTimeoutMinutes = 3 + }; + + return new CERTInextCAPlugin(_fixture.Client, BuildDnsFactory(), config); + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + /// + /// Enroll with DCV enabled. Uses a real Cloudflare DNS record when CF credentials + /// are configured, otherwise uses . + /// + /// The test verifies that the plugin completes without throwing. The enrollment + /// result status depends on whether the CERTInext sandbox auto-issues after DCV. + /// + [SkippableFact] + public async Task DcvEnroll_CompletesWithoutThrowing() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var plugin = BuildPlugin(dcvEnabled: true); + + var result = await plugin.Enroll( + csr: GenerateCsrPem(IntegrationTestData.DcvTestDomain), + subject: $"CN={IntegrationTestData.DcvTestDomain}", + san: new Dictionary + { + ["dns"] = new[] { IntegrationTestData.DcvTestDomain } + }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + _output.WriteLine($"Domain: {IntegrationTestData.DcvTestDomain}"); + _output.WriteLine($"CARequestID: {result.CARequestID}"); + _output.WriteLine($"Status: {result.Status}"); + _output.WriteLine($"Message: {result.StatusMessage}"); + + if (_fixture.IsCloudflareConfigured) + { + // With real DNS, CERTInext should be able to verify — assert issuance or pending + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION } + .Should().Contain(result.Status, + "enrollment with real DNS DCV should produce a valid terminal or pending status"); + } + else + { + // Without real DNS the VerifyDcv may fail; we only assert no unhandled exception + // was thrown (the Enroll method handles the error gracefully). + result.Should().NotBeNull("enrollment should return a result even when stub DNS is used"); + } + } + + /// + /// Enroll without DCV enabled — verifies the plugin skips the DCV path entirely + /// and returns a result from the normal enrollment flow. + /// + [SkippableFact] + public async Task EnrollWithoutDcv_DoesNotInvokeDnsProvider() + { + IntegrationSkip.IfNotConfigured(_fixture); + + // Use a plugin backed by the real client but DcvEnabled=false + var plugin = BuildPlugin(dcvEnabled: false); + + var result = await plugin.Enroll( + csr: GenerateCsrPem(IntegrationTestData.DcvTestDomain), + subject: $"CN={IntegrationTestData.DcvTestDomain}", + san: new Dictionary + { + ["dns"] = new[] { IntegrationTestData.DcvTestDomain } + }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + } + + /// + /// End-to-end "DCV mode off" scenario, mirroring how a v3.2 gateway host would + /// experience the plugin (no IDomainValidatorFactory available, so DCV silently + /// no-ops). Enrolls a fresh domain with DcvEnabled=false, then runs the plugin's + /// own Synchronize and asserts the order surfaces in pending-DCV state. + /// This is the live verification for GitHub issue #7. + /// + /// The CERTInext side may auto-issue some orders very quickly thanks to cached + /// DCV for previously-validated parent domains; this test uses a freshly random + /// subdomain to minimize that but tolerates either pending or issued in the + /// assertion (the real signal we want is "the plugin did not invoke DCV"). + /// + [SkippableFact] + public async Task EnrollWithDcvOff_OrderAppearsInSync_PluginDidNotInvokeDcv() + { + IntegrationSkip.IfNotConfigured(_fixture); + + // Generate a unique CN so prior cached-DCV state on the parent zone doesn't + // bias the result. + string suffix = System.Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"dcv-off-{suffix}.scrup.org"; + + // Plugin built with DCV disabled. BuildPlugin still wires a Cloudflare or stub + // factory but PerformDcvIfNeededAsync gates on _config.DcvEnabled so neither + // factory will be touched on this Enroll path. + var plugin = BuildPlugin(dcvEnabled: false); + + // --- Enroll phase --- + var enrollSw = System.Diagnostics.Stopwatch.StartNew(); + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(cn), + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + enrollSw.Stop(); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace( + "the CA must accept the order even with DCV off — DCV-off ≠ no enrollment"); + + _output.WriteLine($"Enroll completed in {enrollSw.Elapsed:mm\\:ss\\.fff}"); + _output.WriteLine($" CARequestID: {enrollResult.CARequestID}"); + _output.WriteLine($" Status: {enrollResult.Status}"); + _output.WriteLine($" Message: {enrollResult.StatusMessage}"); + + // The plugin's "DCV off" contract: with DcvEnabled=false the plugin does NOT + // wait for issuance. Even if CERTInext later auto-issues from cached DCV, the + // immediate Enroll response should be pending (no issuance polling ran). + // We allow GENERATED too because cached DCV on the parent zone could plausibly + // make CERTInext mark the order issued before its first reply — but the most + // common case is EXTERNALVALIDATION. + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(enrollResult.Status, + $"DCV-off Enroll must return a recognizable terminal/pending state; got {enrollResult.Status}"); + + // --- Sync phase: pull the whole account, find our order --- + var syncSw = System.Diagnostics.Stopwatch.StartNew(); + var synced = await RunSyncAsync(plugin); + syncSw.Stop(); + _output.WriteLine($"Synchronize returned {synced.Count} records in {syncSw.Elapsed:mm\\:ss\\.fff}"); + + var record = synced.FirstOrDefault(r => r.CARequestID == enrollResult.CARequestID); + record.Should().NotBeNull( + $"the enrolled order ({enrollResult.CARequestID}) must appear in plugin.Synchronize results"); + _output.WriteLine($" Sync record status: {record!.Status}"); + + // Final shape assertion: order is in the inventory, and its status is either + // pending (EXTERNALVALIDATION — typical when CERTInext hasn't moved it yet) + // or issued (GENERATED — if CERTInext autoissued from cached DCV). It must + // NOT be FAILED — DCV-off should not produce a failed cert. + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(record.Status, + "the synced record must reflect either pending or issued — never FAILED with DCV off"); + + // Surface the human-readable summary so the live behavior is visible in the + // test output without needing to grep the gateway logs. + _output.WriteLine($"--- Verdict: DCV-off enroll for {cn} succeeded, plugin did not invoke DCV, " + + $"order {enrollResult.CARequestID} surfaced in sync with Status={record.Status}. ---"); + } + + /// + /// Symmetric counterpart to . + /// Drives a fresh enrollment with DCV ON end-to-end against the live sandbox and + /// asserts the issued cert flows through Synchronize. This is the v3.3+ + /// production scenario — plugin places the order, runs DNS TXT staging via + /// Cloudflare, asks CERTInext to verify, waits for issuance, and the resulting + /// GENERATED record surfaces in the gateway's inventory. + /// + [SkippableFact] + public async Task EnrollWithDcvOn_OrderIssuedEndToEnd_AndAppearsInSync() + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV-on test must publish real TXT records."); + + string suffix = System.Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"dcv-on-{suffix}.scrup.org"; + + var plugin = BuildPlugin(dcvEnabled: true); + + // --- Enroll phase --- + var enrollSw = System.Diagnostics.Stopwatch.StartNew(); + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(cn), + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + enrollSw.Stop(); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + _output.WriteLine($"Enroll completed in {enrollSw.Elapsed:mm\\:ss\\.fff}"); + _output.WriteLine($" CARequestID: {enrollResult.CARequestID}"); + _output.WriteLine($" Status: {enrollResult.Status}"); + _output.WriteLine($" Certificate: {(string.IsNullOrWhiteSpace(enrollResult.Certificate) ? "(not in Enroll response)" : enrollResult.Certificate[..60] + "...")}"); + + // Enroll must NOT be FAILED. GENERATED if the bounded issuance wait caught + // the cert before returning; EXTERNALVALIDATION if not — sync will catch it. + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(enrollResult.Status, + $"DCV-on Enroll must return pending or issued; got {enrollResult.Status}"); + + // --- Sync phase --- + var syncSw = System.Diagnostics.Stopwatch.StartNew(); + var synced = await RunSyncAsync(plugin); + syncSw.Stop(); + _output.WriteLine($"Synchronize returned {synced.Count} records in {syncSw.Elapsed:mm\\:ss\\.fff}"); + + var record = synced.FirstOrDefault(r => r.CARequestID == enrollResult.CARequestID); + record.Should().NotBeNull( + $"the enrolled order ({enrollResult.CARequestID}) must appear in plugin.Synchronize results"); + _output.WriteLine($" Sync record status: {record!.Status}"); + _output.WriteLine($" Cert PEM length: {(record.Certificate?.Length ?? 0)}"); + + // The plugin's sync-DCV-retry should have advanced any still-pending orders. + // With Cloudflare DCV available, every DCV-on enrollment should resolve to + // GENERATED by the time sync returns. If we see EXTERNALVALIDATION here it + // means CERTInext's async issuance window is still in flight after our sync — + // worth noting but not a hard failure (the next sync will pick it up). + record.Status.Should().BeOneOf((int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION); + + // Issue 0001: Synchronize now materialises the PEM for issued certs. + // ListCertificatesAsync returns order-report metadata (no body), so the plugin + // refetches the full certificate for GENERATED/REVOKED records during sync. + if (record.Status == (int)EndEntityStatus.GENERATED) + { + record.Certificate.Should().NotBeNullOrWhiteSpace( + "Synchronize must populate the cert body for issued orders (issue 0001) — " + + "the order-report listing carries none, so the plugin refetches it."); + + // GetSingleRecord is the same on-demand fetch the gateway uses for inventory. + var fetched = await plugin.GetSingleRecord(enrollResult.CARequestID); + fetched.Should().NotBeNull(); + fetched.Status.Should().Be((int)EndEntityStatus.GENERATED); + fetched.Certificate.Should().NotBeNullOrWhiteSpace( + "GetSingleRecord must populate the PEM for a GENERATED order."); + _output.WriteLine($" Sync cert PEM length: {record.Certificate!.Length}; " + + $"GetSingleRecord PEM length: {fetched.Certificate!.Length}"); + } + + _output.WriteLine($"--- Verdict: DCV-on enroll for {cn} drove DCV end-to-end via plugin, " + + $"order {enrollResult.CARequestID} surfaced in sync with Status={record.Status}. ---"); + } + + /// + /// End-to-end key-algorithm issuance matrix: RSA 2048/3072/4096/6144/8192, ECDSA + /// P-256/P-384/P-521, Ed25519, Ed448 (see ). For each type, + /// enroll a fresh scrup.org DV order with DCV ON, drive it to issuance via the plugin + /// (Cloudflare TXT publish → VerifyDcv → bounded sync passes), and assert the issued cert + /// carries a parseable body whose public key matches the requested algorithm. + /// + /// An algorithm CERTInext won't issue — rejected at submission, FAILED, or never reaching + /// GENERATED within the polling window — is reported as an explicit Skip carrying the + /// observed reason, so the matrix documents which algorithms CERTInext actually issues + /// without hard-failing on a legitimate CA limitation. + /// + /// Opt-in (issues a real cert per accepted algorithm): set CERTINEXT_ALGO_MATRIX_DCV=1. + /// Requires Cloudflare DCV credentials. + /// + [SkippableTheory] + [MemberData(nameof(KeyAlgorithms.AsMemberData), MemberType = typeof(KeyAlgorithms))] + public async Task EnrollWithDcvOn_IssuesPerKeyAlgorithm(string tag) + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.If(System.Environment.GetEnvironmentVariable("CERTINEXT_ALGO_MATRIX_DCV") != "1", + "Opt-in: set CERTINEXT_ALGO_MATRIX_DCV=1 to issue one real scrup.org cert per key algorithm."); + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV issuance must publish real TXT records."); + + var spec = KeyAlgorithms.For(tag); + string suffix = System.Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"algo-{KeyAlgorithms.Slug(tag)}-{suffix}.scrup.org"; + string csr = KeyAlgorithms.GenerateCsrPem(cn, spec); + + var plugin = BuildPlugin(dcvEnabled: true); + + // --- Enroll. A submission-time rejection (unsupported algorithm) → Skip with the CA's reason. --- + EnrollmentResult enrollResult; + try + { + enrollResult = await plugin.Enroll( + csr: csr, + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + 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} cert: {reason}. CA message: {ex.Message}"); + return; // unreachable — Skip throws + } + + 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}"); + + // --- Poll this one order to issuance via GetSingleRecord (targeted; avoids the + // full-account sync, which would also drive DCV on unrelated pending orders). --- + 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}"); + + // Wait for GENERATED *with a materialized body*. CERTInext flips status to + // GENERATED a beat before GetCertificate returns the PEM, so an order that + // issues quickly can report GENERATED with an empty body for a poll or two. + if (status == (int)EndEntityStatus.GENERATED && !string.IsNullOrWhiteSpace(record?.Certificate)) + break; + if (status == (int)EndEntityStatus.FAILED) + { + _output.WriteLine($"[SKIP] {tag}: order {enrollResult.CARequestID} went FAILED — CERTInext will not issue this algorithm."); + Skip.If(true, $"CERTInext FAILED the {tag} order — algorithm not issuable on this account/profile."); + return; + } + 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) + { + // Accepted at submission but not issued within the window — document as Skip, not fail. + _output.WriteLine($"[SKIP] {tag}: order {enrollResult.CARequestID} still Status={record.Status} after {maxPolls} polls."); + Skip.If(true, $"CERTInext accepted the {tag} order but it did not reach GENERATED within the polling window " + + $"(Status={record.Status}) — possible unsupported algorithm or slow server-side validation."); + return; + } + + record.Certificate.Should().NotBeNullOrWhiteSpace( + $"{tag}: issued cert must carry a PEM body (issue 0001)"); + + // Strong check: the issued cert's public key must match the algorithm we requested. + AssertIssuedCertMatchesAlgorithm(record.Certificate, spec, tag); + + _output.WriteLine($"--- {tag}: DCV-on issuance OK — order {enrollResult.CARequestID} GENERATED, " + + $"cert public key confirmed as {tag}. ---"); + } + + /// + /// Parses an issued certificate PEM and asserts its public key matches the requested + /// algorithm/size — proves CERTInext issued the key type we submitted, not a substitute. + /// + 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; + } + } + + /// + /// Exercises the deferred-DCV retry path during single-record refresh against an + /// existing pending order. Reads CERTINEXT_PENDING_ORDER_ID from the + /// environment; the test is skipped if not set, since this scenario requires a + /// real order that CERTInext has parked at Pending System RA with + /// dcvStatus=0 after the initial enrollment. + /// + /// On success, GetSingleRecord drives DCV (Cloudflare TXT publish → + /// CERTInext VerifyDcv → wait for verification → cleanup) and returns either an + /// issued record () or a still-pending + /// record if CERTInext has not finished server-side validation yet. + /// + [SkippableFact] + public async Task GetSingleRecord_DrivesDcvForPendingOrder() + { + IntegrationSkip.IfNotConfigured(_fixture); + + string orderId = System.Environment.GetEnvironmentVariable("CERTINEXT_PENDING_ORDER_ID"); + Skip.If(string.IsNullOrWhiteSpace(orderId), + "Set CERTINEXT_PENDING_ORDER_ID to a real pending-DCV order to run this test."); + + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin " + + "can publish a real TXT record for CERTInext to verify."); + + // DCV must be enabled and a real DNS provider must be wired up — otherwise the + // sync-retry helper short-circuits with no effect. + var plugin = BuildPlugin(dcvEnabled: true); + + var record = await plugin.GetSingleRecord(orderId); + + record.Should().NotBeNull(); + _output.WriteLine($"CARequestID: {record.CARequestID}"); + _output.WriteLine($"Status: {record.Status}"); + _output.WriteLine($"Certificate: {(string.IsNullOrWhiteSpace(record.Certificate) ? "(not yet issued)" : record.Certificate[..60] + "...")}"); + + // We assert no unhandled exception was thrown and a record came back. The exact + // final status is environment-dependent (CERTInext may still be working through + // VerifyDcv even after the plugin returns), so we accept either GENERATED or + // a still-pending EXTERNALVALIDATION status here — the regression we're guarding + // against is the silent no-op the plugin used to do on this path. + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION } + .Should().Contain(record.Status, + "deferred-DCV retry should leave the order in a valid pending or issued state"); + } + + /// + /// Volume / pagination smoke test — enrolls a configurable number of DV orders + /// concurrently (default 101) against fresh unique subdomains, then runs + /// plugin.Synchronize with the connector's PageSize=100 to verify + /// (a) every order issued, (b) every order shows up in sync, and (c) the sync + /// iterator correctly crosses the 100-record page boundary in + /// ListCertificatesAsync. + /// + /// This is an opt-in test because it places real CA orders and takes several + /// minutes. Set CERTINEXT_RUN_BULK_TEST=1 in the environment to run. + /// Override the count with CERTINEXT_BULK_TEST_COUNT (default 101) and + /// the concurrency cap with CERTINEXT_BULK_TEST_PARALLEL (default 5). + /// + [SkippableFact] + public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks() + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.If(System.Environment.GetEnvironmentVariable("CERTINEXT_RUN_BULK_TEST") != "1", + "Opt-in: set CERTINEXT_RUN_BULK_TEST=1 to run the volume/pagination test."); + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — bulk test must publish real TXT records."); + + int count = int.TryParse(System.Environment.GetEnvironmentVariable("CERTINEXT_BULK_TEST_COUNT"), out int c) + ? c : 101; + int parallel = int.TryParse(System.Environment.GetEnvironmentVariable("CERTINEXT_BULK_TEST_PARALLEL"), out int p) + ? p : 5; + + // PageSize=100 ensures the 101st order forces a second page during Synchronize. + var plugin = BuildPlugin(dcvEnabled: true, propagationDelaySeconds: 5, pageSize: 100); + + // --- Phase 1: bounded-parallel enrollments --- + var enrolled = new System.Collections.Concurrent.ConcurrentBag<(int idx, string cn, EnrollmentResult result)>(); + var failures = new System.Collections.Concurrent.ConcurrentBag<(int idx, string cn, string error)>(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + using (var sem = new System.Threading.SemaphoreSlim(parallel, parallel)) + { + var tasks = Enumerable.Range(0, count).Select(async i => + { + await sem.WaitAsync(); + try + { + // Unique CN per order — uses Guid hex prefix so reruns don't collide. + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"bulk-{suffix}.scrup.org"; + string csr = GenerateCsrPem(cn); + + var result = await plugin.Enroll( + csr: csr, + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + 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, $"#{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 Enroll() call must succeed (the plugin's EMS-956 tolerance means even pending DCV returns gracefully); " + + $"got {failures.Count} hard failures."); + enrolled.Count.Should().Be(count, $"expected {count} successful Enroll() calls"); + + var enrolledIds = enrolled + .Where(e => !string.IsNullOrEmpty(e.result.CARequestID)) + .Select(e => e.result.CARequestID) + .ToHashSet(); + enrolledIds.Count.Should().Be(count, "every enrollment must return a CARequestID"); + + // --- Phase 2: Synchronize until every enrolled order reaches GENERATED --- + // + // CERTInext's pipeline is async: VerifyDcv triggers a server-side DNS-01 check + // and certificate generation that completes a few seconds *after* the plugin's + // Enroll() returns. A single Synchronize captures whatever state CERTInext has + // settled at that exact moment, so a chunk of orders typically remain at + // EXTERNALVALIDATION on the first pass. The sync-driven DCV retry in the plugin + // handles staggered completion across subsequent gateway sync cycles — so this + // test mimics that by running Synchronize repeatedly until either all 101 are + // GENERATED or a bounded number of attempts is exhausted. + const int maxSyncPasses = 8; + const int delayBetweenPassesSeconds = 30; + + List synced = null; + System.Diagnostics.Stopwatch syncPhaseSw = System.Diagnostics.Stopwatch.StartNew(); + int passesUsed = 0; + int finalNotIssued = -1; + + for (int pass = 1; pass <= maxSyncPasses; pass++) + { + passesUsed = pass; + var passSw = System.Diagnostics.Stopwatch.StartNew(); + synced = await RunSyncAsync(plugin); + passSw.Stop(); + + int generated = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.GENERATED); + int pending = enrolledIds.Count - generated; + finalNotIssued = pending; + + _output.WriteLine( + $"--- Sync pass #{pass}: returned {synced.Count} records, {generated}/{enrolledIds.Count} GENERATED, " + + $"{pending} still pending, elapsed={passSw.Elapsed:mm\\:ss} ---"); + + if (pending == 0) + break; + + if (pass < maxSyncPasses) + { + _output.WriteLine($" Waiting {delayBetweenPassesSeconds}s before next sync pass…"); + await Task.Delay(TimeSpan.FromSeconds(delayBetweenPassesSeconds)); + } + } + syncPhaseSw.Stop(); + + // Pagination check — sync must have returned strictly more than one page. + synced!.Count.Should().BeGreaterThan(100, + "with 101 freshly-enrolled orders + any pre-existing, sync must return >100 records " + + "to prove the ListCertificatesAsync paginator crossed PageSize=100."); + + // Every enrolled CARequestID must show up. + var syncedIds = synced.Select(r => r.CARequestID).ToHashSet(); + var missing = enrolledIds.Where(id => !syncedIds.Contains(id)).ToList(); + missing.Should().BeEmpty( + $"{missing.Count} enrolled orders did not appear in sync results: " + + $"{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); + var notIssued = enrolledIds + .Select(id => lookup[id]) + .Where(r => r.Status != (int)EndEntityStatus.GENERATED) + .ToList(); + + if (notIssued.Count > 0) + { + _output.WriteLine($"--- After {passesUsed} sync passes, {notIssued.Count} order(s) still not GENERATED: ---"); + foreach (var r in notIssued.Take(10)) + _output.WriteLine($" {r.CARequestID} Status={r.Status}"); + } + + 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)."); + + _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} ---"); + } + + /// + /// Operational task: drive every existing pending-DV order to completion. + /// + /// Unlike , this enrolls + /// nothing — it just runs the plugin's full Synchronize with DCV enabled, which + /// invokes TryRunDcvDuringSyncAsync for every order sitting at + /// (Cloudflare TXT publish → VerifyDcv → + /// wait → cleanup). It repeats the sync until no order remains pending or the pass budget + /// is exhausted, reporting which orders transitioned to . + /// + /// Opt-in (it mutates real CA orders and publishes real DNS records): set + /// CERTINEXT_COMPLETE_PENDING=1. Requires Cloudflare DCV credentials. + /// + [SkippableFact] + public async Task CompleteAllPendingDvOrders() + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.If(System.Environment.GetEnvironmentVariable("CERTINEXT_COMPLETE_PENDING") != "1", + "Opt-in: set CERTINEXT_COMPLETE_PENDING=1 to drive all pending DV orders to completion."); + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — completing DCV must publish real TXT records."); + + var plugin = BuildPlugin(dcvEnabled: true); + + const int maxSyncPasses = 8; + const int delayBetweenPassesSeconds = 30; + + List synced = null; + int passesUsed = 0; + var phaseSw = System.Diagnostics.Stopwatch.StartNew(); + + for (int pass = 1; pass <= maxSyncPasses; pass++) + { + passesUsed = pass; + var passSw = System.Diagnostics.Stopwatch.StartNew(); + synced = await RunSyncAsync(plugin); + passSw.Stop(); + + var pending = synced.Where(r => r.Status == (int)EndEntityStatus.EXTERNALVALIDATION).ToList(); + int generated = synced.Count(r => r.Status == (int)EndEntityStatus.GENERATED); + + _output.WriteLine( + $"--- Sync pass #{pass}: {synced.Count} records, {generated} GENERATED, " + + $"{pending.Count} still pending DV, elapsed={passSw.Elapsed:mm\\:ss} ---"); + foreach (var r in pending.Take(20)) + _output.WriteLine($" pending: {r.CARequestID}"); + + if (pending.Count == 0) + break; + + if (pass < maxSyncPasses) + { + _output.WriteLine($" Waiting {delayBetweenPassesSeconds}s before next sync pass…"); + await Task.Delay(TimeSpan.FromSeconds(delayBetweenPassesSeconds)); + } + } + phaseSw.Stop(); + + synced.Should().NotBeNull("Synchronize must have run at least once"); + var stillPending = synced!.Where(r => r.Status == (int)EndEntityStatus.EXTERNALVALIDATION).ToList(); + + _output.WriteLine( + $"--- Done after {passesUsed} pass(es) in {phaseSw.Elapsed:mm\\:ss}: " + + $"{synced!.Count(r => r.Status == (int)EndEntityStatus.GENERATED)} GENERATED, " + + $"{stillPending.Count} still pending DV. ---"); + + // Orders may legitimately remain pending if CERTInext is still working server-side or + // a domain isn't in the configured Cloudflare zone — surface that rather than failing. + stillPending.Should().BeEmpty( + $"all pending DV orders should reach GENERATED after {maxSyncPasses} passes; " + + $"{stillPending.Count} remain (e.g. {string.Join(", ", stillPending.Take(5).Select(r => r.CARequestID))}). " + + "These likely have domains outside the configured Cloudflare zone or are still validating server-side."); + } + + // Regression for issue 0001 — a full Synchronize must return every issued cert WITH + // its PEM body. The order-report listing carries no body, so the plugin must refetch + // the full certificate; before the fix, issued certs synced with a null body and + // never appeared in Command. This is the end-to-end "issued certs fill in" check. + [SkippableFact] + public async Task FullSync_AllIssuedCerts_CarryParseableCertificateBody() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var plugin = BuildPlugin(dcvEnabled: false); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var synced = await RunSyncAsync(plugin); + sw.Stop(); + + var issued = synced.Where(r => r.Status == (int)EndEntityStatus.GENERATED).ToList(); + _output.WriteLine( + $"Synchronize returned {synced.Count} records in {sw.Elapsed:mm\\:ss} ({issued.Count} GENERATED)."); + + issued.Should().NotBeEmpty( + "the account has known issued certs (e.g. scrup.org) that a full sync must surface"); + + var parser = new Org.BouncyCastle.X509.X509CertificateParser(); + var bad = new System.Collections.Generic.List(); + foreach (var r in issued) + { + if (string.IsNullOrWhiteSpace(r.Certificate)) + { + bad.Add($"{r.CARequestID} (empty body)"); + continue; + } + try + { + var b64 = r.Certificate + .Replace("-----BEGIN CERTIFICATE-----", string.Empty) + .Replace("-----END CERTIFICATE-----", string.Empty) + .Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + if (parser.ReadCertificate(Convert.FromBase64String(b64)) == null) + bad.Add($"{r.CARequestID} (unparseable)"); + } + catch (Exception ex) + { + bad.Add($"{r.CARequestID} ({ex.GetType().Name})"); + } + } + + bad.Should().BeEmpty( + "every issued cert must carry a parseable certificate body after sync; " + + $"offenders: {string.Join(", ", bad.Take(10))}"); + _output.WriteLine($"--- Verdict: all {issued.Count} issued certs carry a valid certificate body. ---"); + } + } + + /// + /// Shared test data for DCV integration tests. + /// + internal static class IntegrationTestData + { + /// + /// Domain used for DCV tests. Override via CERTINEXT_DCV_DOMAIN in + /// ~/.env_certinext. + /// + public static string DcvTestDomain => + System.Environment.GetEnvironmentVariable("CERTINEXT_DCV_DOMAIN") + ?? "dcv-test.example.com"; + + public static EnrollmentProductInfo DvSslProductInfo(string productCode = null) => + new EnrollmentProductInfo + { + ProductID = productCode ?? Constants.Products.DvSsl, + ProductParameters = new Dictionary + { + ["ProfileId"] = productCode ?? Constants.Products.DvSsl, + ["ValidityYears"] = "1" + } + }; + } +} diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md index c57d0f5..441f573 100644 --- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md +++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md @@ -8,7 +8,7 @@ so the project is safe to include in CI pipelines that do not have API access. ## Prerequisites -- .NET 8 SDK +- .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** @@ -119,7 +119,6 @@ pipeline failure. | Test | What it checks | |------|---------------| | `GetOrderReport_ReturnsOrders` | Fetches page 1; asserts at least one order is returned | -| `GetOrderReport_ContainsKnownDraftOrder` | Fetches all pages; asserts requestNumber `4572531551` (DV SSL 838 draft) is present | | `GetOrderReport_AllOrders_HaveRequiredFields` | For each order on page 1: `requestNumber`, `productCode`, and `orderDate` are non-empty | ### `PluginSmokeTests` @@ -162,4 +161,3 @@ never transmitted over the wire — only the derived `authKey` hash is sent. | `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) | -| `ContainsKnownDraftOrder` fails | Draft order `4572531551` not on this account | Update `KnownDraftRequestNumber` in `OrderReportTests.cs` to a request number from your account | diff --git a/CERTInext.IntegrationTests/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs index 8147730..8e4f637 100644 --- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs +++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs @@ -33,6 +33,22 @@ public sealed class IntegrationTestFixture : IDisposable public string RequestorEmail { get; } public string RequestorName { get; } + // --------------------------------------------------------------------------- + // Cloudflare DCV credentials (optional) + // --------------------------------------------------------------------------- + + /// Cloudflare API token with DNS:Edit permission on . + public string CloudflareApiToken { get; } + + /// Cloudflare Zone ID for the domain used in DCV integration tests. + public string CloudflareZoneId { get; } + + /// + /// True when Cloudflare credentials are present, enabling real DNS DCV tests. + /// When false, DCV integration tests fall back to a . + /// + public bool IsCloudflareConfigured { get; } + /// /// True when at minimum ApiUrl and AccessKey are both non-empty, /// indicating that live credential configuration is present. @@ -67,6 +83,12 @@ 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, "CERTINEXT_API_URL"); AccessKey = GetEnvValue(env, "CERTINEXT_ACCESS_KEY"); AccountNumber = GetEnvValue(env, "CERTINEXT_ACCOUNT_NUMBER"); @@ -76,6 +98,11 @@ public IntegrationTestFixture() 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"); + IsCloudflareConfigured = !string.IsNullOrWhiteSpace(CloudflareApiToken) && + !string.IsNullOrWhiteSpace(CloudflareZoneId); + IsConfigured = !string.IsNullOrWhiteSpace(ApiUrl) && !string.IsNullOrWhiteSpace(AccessKey); @@ -88,6 +115,7 @@ public IntegrationTestFixture() ApiKey = AccessKey, AccountNumber = AccountNumber, GroupNumber = GroupNumber, + OrganizationNumber = OrgNumber, RequestorName = string.IsNullOrWhiteSpace(RequestorName) ? "Keyfactor Integration Test" : RequestorName, @@ -131,7 +159,7 @@ private static Dictionary LoadEnvFile(string path) continue; string key = line.Substring(0, idx).Trim(); - string val = line.Substring(idx + 1).Trim(); + string val = ParseEnvValue(line.Substring(idx + 1)); result[key] = val; } } @@ -148,6 +176,28 @@ private static Dictionary LoadEnvFile(string path) return result; } + /// + /// Parses a raw value from a KEY=VALUE env-file line: trims surrounding + /// whitespace, then strips a single pair of matching surrounding double or single + /// quotes if present. Without quote stripping a line like + /// CERTINEXT_REQUESTOR_NAME="Keyfactor Plugin Test" would parse as the 24-char + /// literal "Keyfactor Plugin Test" (quotes included), diverging from any + /// other shell-style env consumer reading the same file. See GitHub issue #8. + /// Exposed internal for direct unit-testing. + /// + internal static string ParseEnvValue(string rawValue) + { + if (rawValue is null) return string.Empty; + string val = rawValue.Trim(); + if (val.Length >= 2 && + ((val[0] == '"' && val[val.Length - 1] == '"') || + (val[0] == '\'' && val[val.Length - 1] == '\''))) + { + val = val.Substring(1, val.Length - 2); + } + return val; + } + private static string GetEnvValue(Dictionary env, string key) { return env.TryGetValue(key, out string val) ? val : string.Empty; diff --git a/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs new file mode 100644 index 0000000..1db8470 --- /dev/null +++ b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs @@ -0,0 +1,53 @@ +// Copyright 2024 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.IntegrationTests +{ + /// + /// Pure unit tests (no live-API dependency) for the env-file parser used by + /// . See GitHub issue #8 — without quote + /// stripping, a shell-style quoted line was being parsed with the quote characters + /// included in the value. + /// + public class IntegrationTestFixtureTests + { + [Theory] + [InlineData("plain", "plain")] + [InlineData(" plain ", "plain")] + [InlineData("\"Keyfactor Plugin Test\"", "Keyfactor Plugin Test")] + [InlineData(" \"Keyfactor Plugin Test\" ", "Keyfactor Plugin Test")] + [InlineData("'single quoted'", "single quoted")] + [InlineData("\"\"", "")] // empty quoted string + [InlineData("''", "")] // empty single-quoted + [InlineData("\"un-paired'", "\"un-paired'")] // mismatched quotes — leave alone + [InlineData("\"", "\"")] // single naked quote, length<2 after trim — leave alone + [InlineData("", "")] + [InlineData(" ", "")] + public void ParseEnvValue_HandlesQuotingAndWhitespace(string input, string expected) + { + IntegrationTestFixture.ParseEnvValue(input).Should().Be(expected); + } + + [Fact] + public void ParseEnvValue_NullInput_ReturnsEmptyString() + { + IntegrationTestFixture.ParseEnvValue(null).Should().Be(string.Empty); + } + + [Fact] + public void ParseEnvValue_DoesNotStripEmbeddedQuotes() + { + // Quotes in the middle of the value must NOT be stripped; only matching + // outer wrappers count. + IntegrationTestFixture.ParseEnvValue("foo\"bar\"baz") + .Should().Be("foo\"bar\"baz"); + } + } +} diff --git a/CERTInext.IntegrationTests/KeyAlgorithms.cs b/CERTInext.IntegrationTests/KeyAlgorithms.cs new file mode 100644 index 0000000..6f2489b --- /dev/null +++ b/CERTInext.IntegrationTests/KeyAlgorithms.cs @@ -0,0 +1,137 @@ +// Copyright 2024 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 + +using System; +using System.Collections.Generic; +using System.Linq; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Sec; +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; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + internal enum KeyKind { Rsa, Ecdsa, Ed25519, Ed448 } + + /// One row of the key-algorithm coverage matrix. + internal sealed class KeyAlgorithmSpec + { + public string Tag; // stable, human-readable id ("RSA-2048", "ECDSA-P256", ...) + public KeyKind Kind; + public int Strength; // RSA modulus bits, or EC field size in bits (informational for Ed) + public string SignatureAlgorithm; // BouncyCastle signature-algorithm name used to sign the CSR + public DerObjectIdentifier CurveOid; // EC named-curve OID (null for non-EC) + } + + /// + /// Shared key-algorithm matrix + BouncyCastle CSR generation, used by both the offline + /// submission/round-trip tests (AlgorithmMatrixTests) and the live DCV-issuance + /// theory (DcvLifecycleTests). BouncyCastle only — never BCL crypto. + /// + /// Hash pairing follows the CA/Browser Forum Baseline Requirements: P-256→SHA256, + /// P-384→SHA384, P-521→SHA512. + /// + internal static class KeyAlgorithms + { + public static readonly KeyAlgorithmSpec[] All = + { + new() { Tag = "RSA-2048", Kind = KeyKind.Rsa, Strength = 2048, SignatureAlgorithm = "SHA256withRSA" }, + new() { Tag = "RSA-3072", Kind = KeyKind.Rsa, Strength = 3072, SignatureAlgorithm = "SHA256withRSA" }, + new() { Tag = "RSA-4096", Kind = KeyKind.Rsa, Strength = 4096, SignatureAlgorithm = "SHA256withRSA" }, + new() { Tag = "RSA-6144", Kind = KeyKind.Rsa, Strength = 6144, SignatureAlgorithm = "SHA256withRSA" }, + new() { Tag = "RSA-8192", Kind = KeyKind.Rsa, Strength = 8192, SignatureAlgorithm = "SHA256withRSA" }, + new() { Tag = "ECDSA-P256", Kind = KeyKind.Ecdsa, Strength = 256, SignatureAlgorithm = "SHA256withECDSA", CurveOid = SecObjectIdentifiers.SecP256r1 }, + new() { Tag = "ECDSA-P384", Kind = KeyKind.Ecdsa, Strength = 384, SignatureAlgorithm = "SHA384withECDSA", CurveOid = SecObjectIdentifiers.SecP384r1 }, + new() { Tag = "ECDSA-P521", Kind = KeyKind.Ecdsa, Strength = 521, SignatureAlgorithm = "SHA512withECDSA", CurveOid = SecObjectIdentifiers.SecP521r1 }, + new() { Tag = "Ed25519", Kind = KeyKind.Ed25519, Strength = 256, SignatureAlgorithm = "Ed25519" }, + new() { Tag = "Ed448", Kind = KeyKind.Ed448, Strength = 448, SignatureAlgorithm = "Ed448" }, + }; + + public static KeyAlgorithmSpec For(string tag) => All.Single(s => s.Tag == tag); + + /// xUnit member-data source — one row per key type, keyed by its stable tag. + public static IEnumerable AsMemberData => All.Select(s => new object[] { s.Tag }); + + public static AsymmetricCipherKeyPair GenerateKeyPair(KeyAlgorithmSpec spec) + { + switch (spec.Kind) + { + case KeyKind.Rsa: + { + var gen = new RsaKeyPairGenerator(); + gen.Init(new KeyGenerationParameters(new SecureRandom(), spec.Strength)); + return gen.GenerateKeyPair(); + } + case KeyKind.Ecdsa: + { + var gen = new ECKeyPairGenerator("ECDSA"); + gen.Init(new ECKeyGenerationParameters(spec.CurveOid, new SecureRandom())); + return gen.GenerateKeyPair(); + } + case KeyKind.Ed25519: + { + var gen = new Ed25519KeyPairGenerator(); + gen.Init(new Ed25519KeyGenerationParameters(new SecureRandom())); + return gen.GenerateKeyPair(); + } + case KeyKind.Ed448: + { + var gen = new Ed448KeyPairGenerator(); + gen.Init(new Ed448KeyGenerationParameters(new SecureRandom())); + return gen.GenerateKeyPair(); + } + default: + throw new ArgumentOutOfRangeException(nameof(spec), spec.Kind, "unhandled key kind"); + } + } + + public static string GenerateCsrPem(string commonName, KeyAlgorithmSpec spec) + { + var keyPair = GenerateKeyPair(spec); + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest(spec.SignatureAlgorithm, subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + /// Strips PEM armor and returns the DER bytes of a CSR. + public static byte[] DerFromPem(string pem) + { + var b64 = pem + .Replace("-----BEGIN CERTIFICATE REQUEST-----", string.Empty) + .Replace("-----END CERTIFICATE REQUEST-----", string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim(); + return Convert.FromBase64String(b64); + } + + /// A filesystem/DNS-safe slug for a tag, e.g. "ECDSA-P256" → "ecdsap256". + public static string Slug(string tag) => tag.ToLowerInvariant().Replace("-", string.Empty); + + /// + /// Classifies a CERTInext order-rejection message so the algorithm matrix doesn't + /// conflate "this key algorithm is unsupported" with "the account can't place orders + /// right now". CERTInext's live envelope (observed): RSA 2048/3072/4096 + ECC P-256/P-384 + /// are accepted; larger RSA, P-521, and the Ed* curves return "Invalid key size" / + /// "Something went Wrong". A credit shortfall returns "Insufficient Credits" regardless + /// of algorithm. + /// + public static string ClassifyRejection(string caMessage) + { + caMessage ??= string.Empty; + if (caMessage.IndexOf("Invalid key size", StringComparison.OrdinalIgnoreCase) >= 0) + return "key algorithm/size not supported by CERTInext"; + if (caMessage.IndexOf("Insufficient Credits", StringComparison.OrdinalIgnoreCase) >= 0) + return "CERTInext account is out of credits — algorithm support was not exercised"; + return "rejected by CERTInext"; + } + } +} diff --git a/CERTInext.IntegrationTests/LifecycleTests.cs b/CERTInext.IntegrationTests/LifecycleTests.cs index d58bade..185ff64 100644 --- a/CERTInext.IntegrationTests/LifecycleTests.cs +++ b/CERTInext.IntegrationTests/LifecycleTests.cs @@ -6,14 +6,18 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Keyfactor.AnyGateway.Extensions; using Keyfactor.PKI.Enums.EJBCA; using Xunit; +using Xunit.Abstractions; namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests { @@ -34,10 +38,12 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests public class LifecycleTests : IClassFixture { private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; - public LifecycleTests(IntegrationTestFixture fixture) + public LifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) { _fixture = fixture; + _output = output; } // --------------------------------------------------------------------------- @@ -60,22 +66,15 @@ private CERTInextCAPlugin BuildPlugin() /// private static string GenerateCsrPem(string commonName) { - using var rsa = RSA.Create(2048); + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); - var certReq = new CertificateRequest( - $"CN={commonName}", - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName(commonName); - certReq.CertificateExtensions.Add(sanBuilder.Build()); - - byte[] csrDer = certReq.CreateSigningRequest(); + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); return "-----BEGIN CERTIFICATE REQUEST-----\n" - + Convert.ToBase64String(csrDer, Base64FormattingOptions.InsertLineBreaks) + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + "\n-----END CERTIFICATE REQUEST-----"; } @@ -237,5 +236,6 @@ await revokeAct.Should().NotThrowAsync( (int)EndEntityStatus.REVOKED, "Revoke must return the REVOKED status code on success"); } + } } diff --git a/CERTInext.IntegrationTests/SmokeTests.cs b/CERTInext.IntegrationTests/SmokeTests.cs new file mode 100644 index 0000000..8817413 --- /dev/null +++ b/CERTInext.IntegrationTests/SmokeTests.cs @@ -0,0 +1,199 @@ +// Copyright 2024 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 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Basic smoke tests — one operation per test, no side effects. + /// These verify the API is reachable and returning sensible data without + /// creating or modifying any orders. + /// + /// All tests skip when CERTInext credentials are absent (). + /// + public class SmokeTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + public SmokeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + [SkippableFact] + public async Task Ping_Succeeds() + { + IntegrationSkip.IfNotConfigured(_fixture); + + await _fixture.Client.Invoking(c => c.PingAsync()) + .Should().NotThrowAsync("credentials should be valid and API should be reachable"); + } + + [SkippableFact] + public async Task GetProductDetails_ReturnsProducts() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var products = await _fixture.Client.GetProductDetailsAsync(); + + products.Should().NotBeNullOrEmpty("account must have at least one product configured"); + + foreach (var p in products) + _output.WriteLine($" ProductCode={p.ProductCode} Name={p.ProductName} Type={p.ProductType}"); + } + + [SkippableFact] + public async Task ListOrders_ReturnsFirstPage() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var orders = new List(); + + await foreach (var entry in _fixture.Client.ListOrdersAsync(pageSize: 10)) + { + orders.Add(entry); + if (orders.Count >= 10) break; + } + + orders.Should().NotBeEmpty("sandbox account should have at least one order"); + + _output.WriteLine($"Returned {orders.Count} orders (capped at 10):"); + foreach (var o in orders) + _output.WriteLine($" OrderNumber={o.OrderNumber} Domain={o.DomainName} Status={o.CertificateStatus} Expiry={o.CertificateExpiryDate}"); + } + + [SkippableFact] + public async Task TrackOrder_ReturnsDetails() + { + IntegrationSkip.IfNotConfigured(_fixture); + + string orderId = System.Environment.GetEnvironmentVariable("CERTINEXT_ORDER_ID"); + Skip.If(string.IsNullOrWhiteSpace(orderId), + "Set CERTINEXT_ORDER_ID in ~/.env_certinext to run this test."); + + var response = await _fixture.Client.TrackOrderAsync(orderId); + + response.Should().NotBeNull(); + response.OrderDetails.Should().NotBeNull(); + + var od = response.OrderDetails; + _output.WriteLine($"OrderNumber: {orderId}"); + _output.WriteLine($"OrderStatus: {od.OrderStatus} (id={od.OrderStatusId})"); + _output.WriteLine($"CertificateStatus: {od.CertificateStatus} (id={od.CertificateStatusId})"); + _output.WriteLine($"CertificateExpiry: {od.CertificateExpiryDate}"); + _output.WriteLine($"TrackingUrl: {od.TrackingUrl}"); + + if (od.DomainVerification != null) + { + foreach (var kv in od.DomainVerification.GetDomainEntries()) + _output.WriteLine($" Domain [{kv.Key}]: dcvMethod={kv.Value.DcvMethod} dcvStatus={kv.Value.DcvStatus} verifiedDate={kv.Value.VerifiedDate}"); + } + } + + [SkippableFact] + public async Task GetSingleRecord_ReturnsRecord() + { + IntegrationSkip.IfNotConfigured(_fixture); + + string orderId = System.Environment.GetEnvironmentVariable("CERTINEXT_ORDER_ID"); + Skip.If(string.IsNullOrWhiteSpace(orderId), + "Set CERTINEXT_ORDER_ID in ~/.env_certinext to run this test."); + + var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config); + var record = await plugin.GetSingleRecord(orderId); + + record.Should().NotBeNull(); + + _output.WriteLine($"CARequestID: {record.CARequestID}"); + _output.WriteLine($"Status: {record.Status}"); + _output.WriteLine($"Certificate: {(string.IsNullOrWhiteSpace(record.Certificate) ? "(not yet issued)" : record.Certificate[..60] + "...")}"); + } + + /// + /// Exercises against every order + /// returned by ListOrdersAsync. Validates that the per-order plugin + /// code path (TrackOrder → GetCertificate → AnyCAPluginCertificate mapping) + /// succeeds for every order on the account, regardless of certificate status. + /// + [SkippableFact] + public async Task GetSingleRecord_ForAllOrders_AllSucceed() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config); + + var orderNumbers = new List(); + await foreach (var entry in _fixture.Client.ListOrdersAsync()) + { + if (!string.IsNullOrWhiteSpace(entry.OrderNumber)) + orderNumbers.Add(entry.OrderNumber); + } + + orderNumbers.Should().NotBeEmpty("sandbox account should have at least one order"); + _output.WriteLine($"Calling GetSingleRecord for {orderNumbers.Count} order(s):"); + + var failures = new List<(string Order, string Error)>(); + foreach (var orderId in orderNumbers) + { + try + { + var record = await plugin.GetSingleRecord(orderId); + string certPreview = string.IsNullOrWhiteSpace(record.Certificate) + ? "(none)" + : $"{record.Certificate.Length} chars"; + _output.WriteLine($" [OK] Order={orderId} Status={record.Status} Cert={certPreview}"); + } + catch (Exception ex) + { + failures.Add((orderId, ex.Message)); + _output.WriteLine($" [FAIL] Order={orderId} Error={ex.Message}"); + } + } + + failures.Should().BeEmpty( + $"every order's GetSingleRecord call should succeed; {failures.Count} failed: " + + string.Join("; ", failures.Select(f => $"{f.Order}={f.Error}"))); + } + + [SkippableFact] + public async Task Synchronize_DumpsAllRecords() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config); + + var records = new List(); + var blockingCollection = new System.Collections.Concurrent.BlockingCollection(); + + var syncTask = plugin.Synchronize(blockingCollection, lastSync: null, fullSync: true, cancelToken: default); + var collectTask = Task.Run(() => + { + foreach (var r in blockingCollection.GetConsumingEnumerable()) + records.Add(r); + }); + + await syncTask; + blockingCollection.CompleteAdding(); + await collectTask; + + records.Should().NotBeEmpty("sandbox account should have at least one order"); + + _output.WriteLine($"Synchronized {records.Count} records:"); + foreach (var r in records.Take(20)) + _output.WriteLine($" CARequestID={r.CARequestID} Status={r.Status}"); + + if (records.Count > 20) + _output.WriteLine($" ... and {records.Count - 20} more"); + } + } +} diff --git a/CERTInext.IntegrationTests/StubDomainValidator.cs b/CERTInext.IntegrationTests/StubDomainValidator.cs new file mode 100644 index 0000000..2493021 --- /dev/null +++ b/CERTInext.IntegrationTests/StubDomainValidator.cs @@ -0,0 +1,37 @@ +// Copyright 2024 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 + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// No-op DNS validator used when Cloudflare credentials are not available. + /// Records are not actually published; DCV verification by CERTInext may or may + /// not succeed depending on whether the sandbox enforces real DNS lookups. + /// + internal sealed class StubDomainValidator : IDomainValidator + { + public void Initialize(IDomainValidatorConfigProvider configProvider) { } + + public Task StageValidation(string key, string value, CancellationToken cancellationToken) => + Task.FromResult(new DomainValidationResult { Success = true }); + + public Task CleanupValidation(string key, CancellationToken cancellationToken) => + Task.FromResult(new DomainValidationResult { Success = true }); + + public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask; + public Dictionary GetDomainValidatorAnnotations() => new(); + public string GetValidationType() => "dns-01"; + } + + internal sealed class StubDomainValidatorFactory : IDomainValidatorFactory + { + private readonly IDomainValidator _validator = new StubDomainValidator(); + public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator; + } +} diff --git a/CERTInext.IntegrationTests/TESTING.md b/CERTInext.IntegrationTests/TESTING.md index 21e4de1..b961130 100644 --- a/CERTInext.IntegrationTests/TESTING.md +++ b/CERTInext.IntegrationTests/TESTING.md @@ -41,7 +41,7 @@ which ones return a `requestNumber` (valid) vs. an error (invalid or not provisi ## Prerequisites -- .NET 8 SDK +- .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** diff --git a/CERTInext.Tests/BoundedDcvSyncTests.cs b/CERTInext.Tests/BoundedDcvSyncTests.cs new file mode 100644 index 0000000..96b4e3b --- /dev/null +++ b/CERTInext.Tests/BoundedDcvSyncTests.cs @@ -0,0 +1,124 @@ +using System; +using FluentAssertions; +using Xunit; +using static Keyfactor.Extensions.CAPlugin.CERTInext.CERTInextCAPlugin; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0002 — unit tests for the DCV-during-sync gate (EvaluateDcvSyncEligibility). + /// Pure decision logic that bounds DCV work per sync pass so a large pending backlog + /// can't make a pass slow. No DCV machinery / network needed. + /// + public class BoundedDcvSyncTests + { + private static readonly DateTime Now = new DateTime(2026, 6, 10, 12, 0, 0, DateTimeKind.Utc); + + // --- Age window --------------------------------------------------------- + + [Fact] + public void RecentOrder_WithinAgeWindow_IsAttempted() + { + var orderDate = Now.AddHours(-1); // 1h old, window 24h + EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50) + .Should().Be(DcvSyncDecision.Attempt); + } + + [Fact] + public void OldOrder_BeyondAgeWindow_IsSkippedByAge() + { + var orderDate = Now.AddHours(-48); // 48h old, window 24h + EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50) + .Should().Be(DcvSyncDecision.SkipByAge); + } + + [Fact] + public void OrderExactlyAtAgeBoundary_IsAttempted() + { + var orderDate = Now.AddHours(-24); // exactly 24h, window 24h → still eligible (<=) + EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50) + .Should().Be(DcvSyncDecision.Attempt); + } + + [Fact] + public void UnknownOrderDate_IsAttempted_NotStarved() + { + EvaluateDcvSyncEligibility(orderDateUtc: null, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50) + .Should().Be(DcvSyncDecision.Attempt); + } + + [Fact] + public void AgeWindowDisabled_OldOrderStillAttempted() + { + var orderDate = Now.AddDays(-30); + EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 0, attemptedSoFar: 0, perPassCap: 50) + .Should().Be(DcvSyncDecision.Attempt); + } + + // --- Per-pass cap ------------------------------------------------------- + + [Fact] + public void UnderCap_IsAttempted() + { + EvaluateDcvSyncEligibility(Now, Now, ageWindowHours: 24, attemptedSoFar: 4, perPassCap: 5) + .Should().Be(DcvSyncDecision.Attempt); + } + + [Fact] + public void AtCap_IsSkippedByCap() + { + EvaluateDcvSyncEligibility(Now, Now, ageWindowHours: 24, attemptedSoFar: 5, perPassCap: 5) + .Should().Be(DcvSyncDecision.SkipByCap); + } + + [Fact] + public void CapDisabled_AlwaysAttemptedRegardlessOfCount() + { + EvaluateDcvSyncEligibility(Now, Now, ageWindowHours: 24, attemptedSoFar: 10_000, perPassCap: 0) + .Should().Be(DcvSyncDecision.Attempt); + } + + // --- Precedence --------------------------------------------------------- + + [Fact] + public void AgeSkip_TakesPrecedenceOverCap() + { + // Old order AND at cap → reported as age skip (age checked first). + var orderDate = Now.AddHours(-48); + EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 5, perPassCap: 5) + .Should().Be(DcvSyncDecision.SkipByAge); + } + + // --- Simulated pass: a backlog of old + a few recent, with a small cap --- + + [Fact] + public void SimulatedPass_OnlyRecentOrdersAttempted_AndCapped() + { + // 100 old (out-of-window) + 10 recent; cap 5. Mirrors the Synchronize loop's + // use of the gate: only recent orders are eligible, and at most `cap` are attempted. + const int ageWindow = 24, cap = 5; + int attempted = 0, skippedAge = 0, skippedCap = 0; + + for (int i = 0; i < 100; i++) // old backlog + Tally(EvaluateDcvSyncEligibility(Now.AddHours(-48), Now, ageWindow, attempted, cap), + ref attempted, ref skippedAge, ref skippedCap); + for (int i = 0; i < 10; i++) // recent + Tally(EvaluateDcvSyncEligibility(Now.AddMinutes(-5), Now, ageWindow, attempted, cap), + ref attempted, ref skippedAge, ref skippedCap); + + attempted.Should().Be(5, "only up to the cap of recent orders are attempted"); + skippedAge.Should().Be(100, "the entire old backlog is skipped by the age window"); + skippedCap.Should().Be(5, "recent orders beyond the cap are deferred to a later pass"); + } + + private static void Tally(DcvSyncDecision d, ref int attempted, ref int skippedAge, ref int skippedCap) + { + switch (d) + { + case DcvSyncDecision.Attempt: attempted++; break; + case DcvSyncDecision.SkipByAge: skippedAge++; break; + case DcvSyncDecision.SkipByCap: skippedCap++; break; + } + } + } +} diff --git a/CERTInext.Tests/CERTInext.Tests.csproj b/CERTInext.Tests/CERTInext.Tests.csproj index 39aed9d..84ce7a6 100644 --- a/CERTInext.Tests/CERTInext.Tests.csproj +++ b/CERTInext.Tests/CERTInext.Tests.csproj @@ -6,12 +6,24 @@ 12.0 false true + + false + $(DefineConstants);SUPPORTS_DCV + + + + + + diff --git a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs index b949293..f684f7d 100644 --- a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs @@ -772,7 +772,7 @@ await plugin.Enroll( enrollmentType: EnrollmentType.New); capturedRequest.Should().NotBeNull(); - capturedRequest.ValidityDays.Should().Be(365); + capturedRequest!.ValidityDays.Should().Be(365); capturedRequest.RequesterName.Should().Be("Jane Smith"); capturedRequest.RequesterEmail.Should().Be("jane@example.com"); capturedRequest.KeyType.Should().Be("RSA2048"); @@ -811,7 +811,7 @@ await plugin.Enroll( capturedRequest.Should().NotBeNull(); // ValidityDays == 0 when parse fails, so request should have null - capturedRequest.ValidityDays.Should().BeNull( + capturedRequest!.ValidityDays.Should().BeNull( "invalid ValidityDays should fall back to null (use profile default)"); } @@ -883,7 +883,7 @@ await plugin.Enroll( enrollmentType: EnrollmentType.New); capturedRequest.Should().NotBeNull(); - capturedRequest.Sans.Should().NotBeNull(); + capturedRequest!.Sans.Should().NotBeNull(); capturedRequest.Sans.Should().Contain(s => s.Type == "oid", "unknown SAN type should be passed through as-is"); } diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs new file mode 100644 index 0000000..837ae8d --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -0,0 +1,820 @@ +// Copyright 2024 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 + +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.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Unit tests for the DCV orchestration path inside + /// / + /// . + /// + /// All external dependencies (CERTInext client, DNS validator) are stubbed so + /// no network calls are made. Propagation delay is set to 0 so tests run fast. + /// + public class CERTInextCAPluginDcvTests + { + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static CERTInextConfig DcvConfig( + bool enabled = true, + int propagationDelaySeconds = 1, + int timeoutMinutes = 1, + int dcvWaitForChallengeSeconds = 0, + int dcvWaitForIssuanceSeconds = 0) => + new CERTInextConfig + { + DcvEnabled = enabled, + DcvPropagationDelaySeconds = propagationDelaySeconds, + DcvTimeoutMinutes = timeoutMinutes, + // Default to 0 so existing tests preserve the pre-polling single-check + // 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 + }; + + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildPlugin( + ICERTInextClient client, + IDomainValidatorFactory factory, + CERTInextConfig config = null) => + new CERTInextCAPlugin(client, factory, config ?? DcvConfig()); + + private static EnrollmentProductInfo MakeProductInfo() => + new EnrollmentProductInfo + { + ProductID = MockCertificateData.ProfileIdTls, + ProductParameters = new Dictionary { ["ProfileId"] = MockCertificateData.ProfileIdTls } + }; + + /// + /// Returns a mock client pre-wired for the full happy-path DCV flow: + /// Enroll → TrackOrder (DCV pending) → GetDcv → VerifyDcv → GetCertificate. + /// + private static (Mock mock, FakeDomainValidator validator) HappyPathMocks( + string orderNumber = MockCertificateData.DcvOrderId, + string domain = MockCertificateData.DcvDomain, + string token = MockCertificateData.DcvToken) + { + var mock = NewMock(); + + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = orderNumber, Status = "pending_dcv" }); + + // First call: pending (initial check in PerformDcvIfNeededAsync) + // Subsequent calls: verified (polling in WaitForDcvVerificationAsync) + mock.SetupSequence(c => c.TrackOrderAsync(orderNumber, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse(orderNumber, domain)) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(orderNumber, domain)); + + mock.Setup(c => c.GetDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(token)); + + mock.Setup(c => c.VerifyDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.GetCertificateAsync(orderNumber, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(orderNumber)); + + var validator = new FakeDomainValidator(); + return (mock, validator); + } + + private static Task Enroll(CERTInextCAPlugin plugin) => + plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: $"CN={MockCertificateData.DcvDomain}", + san: new Dictionary { ["dns"] = new[] { MockCertificateData.DcvDomain } }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + // --------------------------------------------------------------------------- + // Happy path + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_HappyPath_StagesVerifiesAndCleansUp() + { + var (mock, validator) = HappyPathMocks(); + // Issuance budget > 0 so the post-DCV GetCertificate poll runs and lifts the + // issued cert out of the mock back into the EnrollmentResult. + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().Contain("BEGIN CERTIFICATE"); + + // Verify Stage was called with the right hostname and token + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain); + validator.StagedRecords.Should().ContainSingle() + .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); + + // Verify Cleanup was called (always, including on success) + validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(expectedHostname); + + mock.Verify(c => c.VerifyDcvAsync( + MockCertificateData.DcvOrderId, + MockCertificateData.DcvDomain, + Constants.Dcv.MethodDnsTxt, + It.IsAny()), Times.Once); + + mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Dcv_HappyPath_UsesCustomTxtTemplate() + { + var (mock, validator) = HappyPathMocks(); + // Issuance budget > 0 so the post-DCV GetCertificate poll runs. + var config = DcvConfig(dcvWaitForIssuanceSeconds: 10); + config.DcvTxtRecordTemplate = "dcv-proof.{0}.acme-corp.com"; + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + await Enroll(plugin); + + string expectedHostname = $"dcv-proof.{MockCertificateData.DcvDomain}.acme-corp.com"; + validator.StagedRecords.Should().ContainSingle().Which.key.Should().Be(expectedHostname); + validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(expectedHostname); + } + + // --------------------------------------------------------------------------- + // DCV skipped conditions + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_Skipped_WhenOrderAlreadyIssued() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.CertId1, Status = "issued", Certificate = MockCertificateData.FakePemCertificate, SerialNumber = "0A1B2C" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.CertId1, It.IsAny())) + .ReturnsAsync(MockCertificateData.AlreadyIssuedTrackResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + // DCV skipped — order was already issued, result comes from EnrollCertificateAsync directly + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + validator.StagedRecords.Should().BeEmpty("DCV should be skipped for already-issued orders"); + validator.CleanedUpKeys.Should().BeEmpty(); + + mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dcv_Skipped_WhenNoDomainVerificationBlock() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = null + } + }); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + // PerformDcvIfNeeded returns false → plugin returns result from EnrollCertificateAsync + var result = await Enroll(plugin); + + validator.StagedRecords.Should().BeEmpty(); + mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dcv_SkipsStaging_AndDoesNotIssuancePoll_WhenAllDomainsAlreadyValidated_AndIssuanceBudgetZero() + { + // With DcvWaitForIssuanceSeconds=0 (the test fixture's DcvConfig default), an + // order with DCV already validated short-circuits: no TXT records staged AND + // no post-DCV GetCertificate poll. Lets sync pick up the cert on its own. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + // domainVerification.status = "1" (Validated) — no pending work + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated + } + } + }); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + await Enroll(plugin); + + validator.StagedRecords.Should().BeEmpty(); + mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + // Issuance budget = 0 means the post-DCV poll short-circuits and GetCertificate + // is never called from this Enroll() path. + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dcv_RunsIssuanceWait_WhenDcvAlreadyValidated_AndIssuanceBudgetPositive() + { + // The cached-DCV gap fix: when CERTInext shows DCV already validated (no work + // for the plugin's DNS-TXT staging) AND the admin has set a positive issuance + // budget, the plugin should poll GetCertificate until the cert is generated + // and return the issued result directly from Enroll() — not leave it for sync. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated + } + } + }); + + // First post-DCV fetch is still pending; second returns issued. + mock.SetupSequence(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.DcvOrderId)) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId)); + + 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, + "the issuance poll must lift the issued cert into the EnrollmentResult, " + + "not let the order fall through to a pending-then-sync round-trip"); + validator.StagedRecords.Should().BeEmpty("no TXT staging is needed when DCV is already validated"); + mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.AtLeast(2), "plugin should have polled at least twice to see the cert transition to issued"); + } + + [Fact] + public async Task Dcv_Skipped_WhenDcvEnabledFalse() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse()); + + var validator = new FakeDomainValidator(); + var config = DcvConfig(enabled: false); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + await Enroll(plugin); + + validator.StagedRecords.Should().BeEmpty("DCV should not run when DcvEnabled=false"); + mock.Verify(c => c.TrackOrderAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Issue #7 — IDomainValidatorFactory is optional / injected post-construction + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_SilentlyNoOps_WhenNoFactoryInjected_AndDcvEnabledTrue() + { + // Simulates a v3.2 gateway host: plugin instantiated via the parameterless + // public production constructor, DcvEnabled=true in the connector config, + // but no IDomainValidatorFactory was injected via SetDomainValidatorFactory + // (because the host's IAnyCAPlugin assembly doesn't even have that interface). + // Enroll must: + // * NOT throw (no missing-type / null-factory exception), + // * NOT touch the CA's TrackOrder for DCV purposes, + // * return the enrollment result the CA gave us (here: pending). + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + + // Internal test ctor with factory = null AND DcvEnabled = true. + var plugin = new CERTInextCAPlugin(mock.Object, domainValidatorFactory: null, DcvConfig(enabled: true)); + + var result = await Enroll(plugin); + + result.Should().NotBeNull(); + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "with no factory the CA's pending response must be passed through unchanged"); + mock.Verify(c => c.TrackOrderAsync(It.IsAny(), It.IsAny()), Times.Never, + "EnrollNewAsync must short-circuit the DCV block when _domainValidatorFactory is null"); + } + + [Fact] + public async Task SetDomainValidatorFactory_AfterConstruction_WiresFactoryForSubsequentEnroll() + { + // The v3.3+ gateway path: host instantiates the plugin via the parameterless + // public constructor, resolves an IDomainValidatorFactory from its own + // service container, then calls SetDomainValidatorFactory(factory) before + // Initialize. Subsequent Enroll() calls must use the injected factory. + var (mock, validator) = HappyPathMocks(); + + // Plugin starts with NO factory — proves the setter does the wire-up, not + // some prior constructor parameter. + var plugin = new CERTInextCAPlugin( + mock.Object, + domainValidatorFactory: null, + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + plugin.SetDomainValidatorFactory(new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "the factory injected via SetDomainValidatorFactory must drive DCV end-to-end"); + validator.StagedRecords.Should().NotBeEmpty( + "SetDomainValidatorFactory must populate _domainValidatorFactory so DCV staging runs"); + } + + [Fact] + public async Task SetDomainValidatorFactory_SecondCall_OverridesFirst() + { + // Property-style setter semantics: the most recent SetDomainValidatorFactory + // call wins. Important for gateway hosts that may resolve a fresh factory + // per-initialize cycle. Tested behaviorally — drive Enroll() and assert + // the SECOND factory's validator received the TXT staging call (no reflection + // on internal fields). + var (mock, _) = HappyPathMocks(); + var firstValidator = new FakeDomainValidator(); + var secondValidator = new FakeDomainValidator(); + + var plugin = new CERTInextCAPlugin( + mock.Object, + domainValidatorFactory: null, + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + // First setter call is ignored by the override; only the second factory's + // validator should ever see traffic. + plugin.SetDomainValidatorFactory(new FakeDomainValidatorFactory(firstValidator)); + plugin.SetDomainValidatorFactory(new FakeDomainValidatorFactory(secondValidator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + firstValidator.StagedRecords.Should().BeEmpty( + "the first factory must be replaced — its validator should never be called"); + secondValidator.StagedRecords.Should().NotBeEmpty( + "the second SetDomainValidatorFactory call must replace the first; its validator drives DCV"); + } + + // --------------------------------------------------------------------------- + // Cancelled/rejected orders short-circuit even with validated DCV state + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("4")] // OrderStatusId 4 = Order Cancelled + [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 + // 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. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = terminalOrderStatusId, + CertificateStatusId = "1", + // Validated DCV state — without the OrderStatusId guard this would + // erroneously trigger the issuance-wait path. + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated + } + } + }); + + var validator = new FakeDomainValidator(); + // Issuance-wait budget > 0 so a wrong-path entry would manifest as a + // GetCertificate call we DON'T expect. + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + 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"); + validator.StagedRecords.Should().BeEmpty( + "DCV staging must not run for a cancelled/rejected order"); + } + + // --------------------------------------------------------------------------- + // Sync path is single-shot for the DCV challenge wait + // --------------------------------------------------------------------------- + + [Fact] + public async Task SyncDcvRetry_DoesSingleShotTrackOrder_WhenChallengeNotReady() + { + // Sync MUST NOT poll the configured DcvWaitForChallengeSeconds budget per + // pending order — that would scale O(orders × 60s) per cycle and tie up + // gateway threads for minutes per sync. When TrackOrder returns null + // domainVerification, sync exits immediately and lets the next sync cycle + // pick the order up. + var mock = NewMock(); + + // High config budget — would normally drive 6+ polls × 5s waits. The sync + // override of 0 must prevent that. + var config = DcvConfig(dcvWaitForChallengeSeconds: 60); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = null + } + }); + + // GetSingleRecord calls GetCertificateAsync first to materialize the record; + // the sync-DCV-retry kicks in afterwards. The pending response keeps the + // retry path engaged so we exercise the override. The assertion below pins + // Times.Exactly(1) on TrackOrderAsync: with override=0, the polling loop + // takes one TrackOrder call, sees domainVerification null, and bails — no + // further polls inside the 60s budget the config nominally allows. + mock.Setup(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.DcvOrderId)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + // GetSingleRecord calls TryRunDcvDuringSyncAsync internally — which is the + // sync-style path with waitForChallengeSecondsOverride=0. + var record = await plugin.GetSingleRecord(MockCertificateData.DcvOrderId); + sw.Stop(); + + record.Should().NotBeNull(); + // The 0-budget single shot must complete well under the 60s config budget. + // Use a generous 10s ceiling to tolerate slow CI hosts; the actual cost is + // ~1 TrackOrder. Without the override we'd be ≥60s. + sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(10), + "sync's DCV retry must be single-shot, not poll the configured challenge budget"); + + mock.Verify(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.Exactly(1), + "PerformDcvIfNeededAsync's single-shot challenge check must make exactly ONE " + + "TrackOrder call when waitForChallengeSecondsOverride=0 and the slot is null. " + + "Without the override, the polling loop would issue many more calls within " + + "the 60s budget."); + } + + // --------------------------------------------------------------------------- + // Failure modes + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_Throws_WhenNoProviderForDomain() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + 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())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse()); + + // Factory returns null → no DNS provider configured + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator: null)); + + Func act = () => Enroll(plugin); + + await act.Should().ThrowAsync() + .WithMessage("*No DNS provider plugin is configured*"); + } + + [Fact] + public async Task Dcv_Throws_WhenStageValidationFails() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + 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())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse()); + + var validator = new FakeDomainValidator { StageSucceeds = false, StageError = "DNS zone not writable" }; + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + + await act.Should().ThrowAsync() + .WithMessage("*Failed to stage DNS validation*DNS zone not writable*"); + + // No VerifyDcv call — failed before reaching that step + mock.Verify(c => c.VerifyDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dcv_CleanupAlwaysCalled_EvenWhenVerifyDcvThrows() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + 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())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse()); + + mock.Setup(c => c.VerifyDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ThrowsAsync(new Exception("CERTInext DNS record not found")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + + await act.Should().ThrowAsync().WithMessage("*DNS record not found*"); + + // Cleanup must run even when VerifyDcv throws + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain); + validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(expectedHostname); + } + + [Fact] + public async Task Dcv_Throws_WhenGetDcvReturnsNoToken() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + 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())) + .ReturnsAsync(new GetDcvResponse { DcvDetails = new DcvResponseDetails { Token = null } }); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + + await act.Should().ThrowAsync() + .WithMessage("*GetDcv returned no token*"); + } + + // --------------------------------------------------------------------------- + // EMS-956 tolerance — see analysis/certinext-support-ticket-2026-05-12.md + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_Defers_When_GetDcv_ReturnsEms956() + { + // Simulates the post-pre-vetted-org behaviour: TrackOrder shows a pending DCV + // slot, but CERTInext's GetDcv endpoint still rejects calls with EMS-956 for a + // window after enrollment. Plugin must NOT throw — it must return the pending + // result so the gateway records the order and the sync-retry can pick it up. + 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 Exception( + "CERTInext GetDcv failed for order '" + MockCertificateData.DcvOrderId + "': EMS-956 Invalid Request for this API.")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + // Should NOT throw — must return pending enrollment result so the gateway + // records the order and lets sync-retry recover later. + var result = await Enroll(plugin); + result.Should().NotBeNull(); + + // The DNS provider must not have been touched — staging a TXT record without a + // valid token would be wasted work and could collide with the future retry. + validator.StagedRecords.Should().BeEmpty(); + validator.CleanedUpKeys.Should().BeEmpty(); + + // VerifyDcv must never be called either. + mock.Verify(c => c.VerifyDcvAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Dcv_Defers_When_GetDcv_ReturnsInvalidRequestMessage_WithoutEms956Code() + { + // Tolerance must also match the human-readable phrase, not only the error code, + // because the CERTInext client wraps non-200 responses in a generic Exception + // whose Message is the upstream errorMessage field (sometimes without the code). + 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 Exception("Invalid Request for this API")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + result.Should().NotBeNull(); + validator.StagedRecords.Should().BeEmpty(); + } + + [Fact] + public async Task Dcv_Rethrows_When_GetDcv_FailsWithUnrelatedError() + { + // 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. + 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 Exception("HTTP 500: Internal Server Error")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + await act.Should().ThrowAsync() + .WithMessage("*HTTP 500*"); + } + + // --------------------------------------------------------------------------- + // DcvWaitForChallengeSeconds — wait for domainVerification to appear + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_WaitsForChallenge_WhenDomainVerificationAppearsLate() + { + // First TrackOrder returns null domainVerification (CERTInext hasn't materialised + // the slot yet), second returns a populated pending slot. With a positive + // DcvWaitForChallengeSeconds the plugin must poll and proceed with DCV, NOT skip. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" }); + + // Sequence: 1st TrackOrder = no DCV slot, 2nd = pending, then verified for the wait poll. + mock.SetupSequence(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = null + } + }) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse()); + + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse()); + mock.Setup(c => c.VerifyDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId)); + + var validator = new FakeDomainValidator(); + // Both budgets positive so the polling paths exercise end-to-end. + var plugin = BuildPlugin( + mock.Object, + new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForChallengeSeconds: 10, dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + validator.StagedRecords.Should().NotBeEmpty("DCV must have run after polling found the slot"); + } + + [Fact] + public async Task Dcv_GivesUpWaitingForChallenge_AfterBudgetExpires() + { + // domainVerification stays null forever. With a short positive budget the plugin + // must poll for the budget and then return false (deferred to sync), NOT throw. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = null + } + }); + + var validator = new FakeDomainValidator(); + // 5-second budget keeps the test fast but tolerates loaded CI hosts where a + // 2-second budget could overshoot to a single poll. + var plugin = BuildPlugin( + mock.Object, + new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForChallengeSeconds: 5)); + + var result = await Enroll(plugin); + + result.Should().NotBeNull(); + validator.StagedRecords.Should().BeEmpty("no DCV slot was ever exposed"); + mock.Verify(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.AtLeast(2), "plugin should have polled at least twice within the 5-second budget"); + } + + // --------------------------------------------------------------------------- + // DcvWaitForIssuanceSeconds — wait for cert PEM after DCV verifies + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_WaitsForIssuance_AfterDcvVerifies() + { + // First post-DCV GetCertificate returns pending; second returns issued. Plugin + // must poll and return the issued result to Enroll(), not the first pending one. + var (mock, validator) = HappyPathMocks(); + + // Override default GetCertificate setup: first pending, then issued. + mock.SetupSequence(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.DcvOrderId)) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId)); + + var plugin = BuildPlugin( + mock.Object, + new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "post-DCV polling must return the issued status, not the first pending fetch"); + mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.AtLeast(2), "plugin should have polled at least twice for issuance"); + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs b/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs new file mode 100644 index 0000000..2fd1ad1 --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs @@ -0,0 +1,196 @@ +// Copyright 2024 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.Linq; +using System.Reflection; +using FluentAssertions; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Pins the gateway-DI-visible public surface of so that + /// regressions which would crash plugin load on older gateway hosts cannot land silently. + /// + /// Background: gateway image 25.4.0 ships + /// Keyfactor.AnyGateway.IAnyCAPlugin v3.2.0.0, which does not define + /// Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory. If any public + /// constructor declares that type as a parameter, the gateway's DI container will fail + /// at RuntimeConstructorInfo.GetParameters() with TypeLoadException 0x80131509 + /// before plugin load can complete (see GitHub issue #7). + /// + /// These tests assert via reflection that the only types reachable from the plugin's + /// public constructor parameter lists are ones present on v3.2 hosts (BCL + + /// pre-3.3 Keyfactor types). + /// + public class CERTInextCAPluginPublicSurfaceTests + { + private static readonly string[] V3Point3OnlyTypeNames = + { + "Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory", + "Keyfactor.AnyGateway.Extensions.IDomainValidator", + "Keyfactor.AnyGateway.Extensions.IDomainValidatorConfigProvider" + }; + + [Fact] + public void NoPublicConstructor_ReferencesV3Point3OnlyTypes() + { + var publicCtors = typeof(CERTInextCAPlugin) + .GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + publicCtors.Should().NotBeEmpty("plugin must have at least one public constructor for the gateway to instantiate"); + + foreach (var ctor in publicCtors) + { + foreach (var param in ctor.GetParameters()) + { + string paramTypeName = param.ParameterType.FullName ?? param.ParameterType.Name; + V3Point3OnlyTypeNames.Should().NotContain(paramTypeName, + $"public constructor parameter '{param.Name}' (type {paramTypeName}) on " + + $"{ctor} would trip TypeLoadException on a gateway whose IAnyCAPlugin " + + $"assembly does not contain that type. Move the constructor to internal " + + $"or remove the parameter — see issue #7."); + } + } + } + + [Fact] + public void NoInstanceField_DeclaredTypeReferencesV3Point3OnlyTypes() + { + // The .NET JIT eagerly resolves the declared types of all instance fields + // when it first compiles ANY method on a class. If an instance field is + // declared with a missing-type-on-this-host type, TypeLoadException fires + // the very first time Initialize / Enroll / Synchronize / anything is + // invoked — independent of whether the field is read on that code path. + // + // Issue #7's original fix patched constructor-signature reflection (the + // DI-container surface). The follow-up comment showed a separate failure + // path where Enroll trips on field-type loading. This test guards against + // a regression of either: field types must use only types the v3.2 host + // ships, with `object` as the typical neutral-typed storage and an `as` + // cast inside method bodies (JIT-lazy) for actual use. + // DeclaredOnly added for symmetry with the nested-type / method tests below + // and to make the "we only check this type, not its base classes" intent + // explicit in the reflection-query shape. + var fields = typeof(CERTInextCAPlugin) + .GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + foreach (var field in fields) + { + string fieldTypeName = field.FieldType.FullName ?? field.FieldType.Name; + V3Point3OnlyTypeNames.Should().NotContain(fieldTypeName, + $"instance field '{field.Name}' (declared type {fieldTypeName}) on " + + $"{field.DeclaringType?.FullName} would trigger TypeLoadException when the JIT " + + $"first compiles any method on the class on a v3.2 gateway host. " + + $"Re-type the field as `object` and cast to the v3.3 type inside method " + + $"bodies — see issue #7 follow-up."); + } + } + + [Fact] + public void NoNestedType_ImplementsV3Point3OnlyInterface() + { + // Nested types declared with a base/interface reference to a v3.3-only + // interface put that interface in the containing class's nested-type + // metadata. CLR class-load behaviour around nested-type interface + // resolution is fragile across .NET versions, so we forbid it outright + // as a belt-and-braces measure. + var nestedTypes = typeof(CERTInextCAPlugin) + .GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic); + + foreach (var nested in nestedTypes) + { + foreach (var iface in nested.GetInterfaces()) + { + string ifaceName = iface.FullName ?? iface.Name; + V3Point3OnlyTypeNames.Should().NotContain(ifaceName, + $"nested type '{nested.FullName}' implements v3.3-only interface " + + $"'{ifaceName}', which would leak into the containing class's " + + $"reflection surface on a v3.2 host. Delete the nested type or " + + $"refactor it to not declare the v3.3 interface in its base list."); + } + } + } + + [Fact] + public void NoPublicMethod_SignatureReferencesV3Point3OnlyTypes() + { + // Reflection-driven hosts (anything calling Type.GetMethods()) eagerly + // resolve return-type and parameter-type metadata on each method. Public + // method signatures must therefore avoid v3.3-only types the same way + // public constructors do. SetDomainValidatorFactory's `object` parameter + // is the safe pattern. + var publicInstanceMethods = typeof(CERTInextCAPlugin) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + foreach (var method in publicInstanceMethods) + { + // Property accessors get caught here too — that's intentional. + string returnTypeName = method.ReturnType.FullName ?? method.ReturnType.Name; + V3Point3OnlyTypeNames.Should().NotContain(returnTypeName, + $"public method '{method.Name}' returns v3.3-only type '{returnTypeName}'. " + + $"Change the return type to `object` and have callers cast at the use site."); + + foreach (var param in method.GetParameters()) + { + string paramTypeName = param.ParameterType.FullName ?? param.ParameterType.Name; + V3Point3OnlyTypeNames.Should().NotContain(paramTypeName, + $"public method '{method.Name}' parameter '{param.Name}' is " + + $"v3.3-only type '{paramTypeName}'. Change the parameter to `object` " + + $"and cast inside the method body — see SetDomainValidatorFactory."); + } + } + } + + [Fact] + public void ParameterlessConstructor_IsPublic() + { + var parameterlessCtor = typeof(CERTInextCAPlugin) + .GetConstructor(BindingFlags.Public | BindingFlags.Instance, types: System.Type.EmptyTypes); + + parameterlessCtor.Should().NotBeNull( + "older gateway hosts that don't pass any DI parameters need a public no-arg " + + "constructor to fall back to. See issue #7."); + } + + [Fact] + public void SetDomainValidatorFactory_AcceptsObject_NotIDomainValidatorFactory() + { + // The public setter must declare `object` (not the v3.3-only interface) so the + // method's signature does not pull the missing type into the v3.2 host's + // reflection surface. + var method = typeof(CERTInextCAPlugin) + .GetMethod("SetDomainValidatorFactory", BindingFlags.Public | BindingFlags.Instance); + + method.Should().NotBeNull("plugin must expose a public hook for v3.3+ hosts to inject the factory"); + var parameters = method!.GetParameters(); + parameters.Should().ContainSingle(); + parameters[0].ParameterType.Should().Be(typeof(object), + "the parameter must be `object` so SetDomainValidatorFactory's signature is " + + "safe to reflect on a v3.2 host. The body casts to IDomainValidatorFactory " + + "lazily, which only resolves the type if the method is actually called."); + } + + [Fact] + public void SetDomainValidatorFactory_NullArgument_LeavesDcvDisabled() + { + var plugin = new CERTInextCAPlugin(); + plugin.SetDomainValidatorFactory(null); + // No exception, no state change — the plugin behaves as if no factory were available. + } + + [Fact] + public void SetDomainValidatorFactory_NonFactoryArgument_IsIgnored() + { + // Pass something that doesn't implement IDomainValidatorFactory. The `as` cast + // in the setter yields null and the field stays null — no throw. + var plugin = new CERTInextCAPlugin(); + plugin.SetDomainValidatorFactory("not a factory"); + // No assertion needed beyond not throwing. + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index 9b85a66..3ec5df1 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -685,6 +685,110 @@ public async Task Synchronize_SkipsFailedCertificates() results[0].CARequestID.Should().Be(MockCertificateData.CertId1); } + // Regression for issue 0001 — Synchronize dropped issued certs because the + // order-report listing (ListCertificatesAsync) carries no PEM body, so the + // synced record had Certificate == null and Command couldn't store it. + [Fact] + public async Task Synchronize_IssuedCertMissingBody_RefetchesFullCertificate() + { + const string id = MockCertificateData.CertId1; + + // Listing entry as the order report produces it: GENERATED status, NO body. + var listingEntry = new LegacyGetCertificateResponse + { + Id = id, + Status = "issued", // → EndEntityStatus.GENERATED + Certificate = null, // order report carries no PEM + ProfileId = MockCertificateData.ProfileIdTls + }; + + var mock = NewMock(); + mock.Setup(c => c.ListCertificatesAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnum(new List { listingEntry })); + // Full fetch returns the PEM body (mirrors the real GetCertificateAsync). + mock.Setup(c => c.GetCertificateAsync(id, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(id)); + + var plugin = BuildPlugin(mock.Object); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: CancellationToken.None); + + var results = buffer.ToList(); + results.Should().HaveCount(1); + results[0].CARequestID.Should().Be(id); + results[0].Certificate.Should().Be(MockCertificateData.FakePemCertificate, + "an issued cert must carry the PEM body fetched via GetCertificateAsync, not a null body"); + mock.Verify(c => c.GetCertificateAsync(id, It.IsAny()), Times.Once); + } + + // Guard the N+1 boundary: when the listing already includes a body, Synchronize + // must NOT refetch. The strict mock has no GetCertificateAsync setup, so any call + // would throw and fail this test. + [Fact] + public async Task Synchronize_IssuedCertWithBody_DoesNotRefetch() + { + var mock = NewMock(); + mock.Setup(c => c.ListCertificatesAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnum(new List + { + MockCertificateData.IssuedCertRecord(MockCertificateData.CertId1) // already has a body + })); + + var plugin = BuildPlugin(mock.Object); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: CancellationToken.None); + + var results = buffer.ToList(); + results.Should().HaveCount(1); + results[0].Certificate.Should().Be(MockCertificateData.FakePemCertificate); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // Regression for issue 0001 (revoked variant) — a cert reported "revoked" during + // sync also arrives from the order report with no body and no revocation detail. + // The refetch must populate the body AND the revocation date, not just the REVOKED + // status. (Complements Synchronize_MapsRevokedCertificates_Correctly, which feeds an + // already-populated entry that doesn't exercise the refetch.) + [Fact] + public async Task Synchronize_RevokedCertMissingBody_RefetchesWithRevocationMetadata() + { + const string id = MockCertificateData.CertId3; + + var listingEntry = new LegacyGetCertificateResponse + { + Id = id, + Status = "revoked", // → EndEntityStatus.REVOKED + Certificate = null, // order report carries neither body nor revocation detail + RevokedAt = null, + ProfileId = MockCertificateData.ProfileIdTls + }; + + var mock = NewMock(); + mock.Setup(c => c.ListCertificatesAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnum(new List { listingEntry })); + mock.Setup(c => c.GetCertificateAsync(id, It.IsAny())) + .ReturnsAsync(MockCertificateData.RevokedCertRecord(id)); // body + RevokedAt + reason + + var plugin = BuildPlugin(mock.Object); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: CancellationToken.None); + + var results = buffer.ToList(); + results.Should().HaveCount(1); + results[0].CARequestID.Should().Be(id); + results[0].Status.Should().Be((int)EndEntityStatus.REVOKED); + results[0].Certificate.Should().Be(MockCertificateData.FakePemCertificate); + results[0].RevocationDate.Should().NotBeNull( + "a revoked cert must carry its revocation date after the sync refetch, not just REVOKED status"); + mock.Verify(c => c.GetCertificateAsync(id, It.IsAny()), Times.Once); + } + [Fact] public async Task Synchronize_HonoursCancellation() { diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs new file mode 100644 index 0000000..4e59495 --- /dev/null +++ b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs @@ -0,0 +1,292 @@ +// Copyright 2024 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.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Verifies the JSON body emitted by BuildOrderRequestFromLegacyEnrollRequest + /// against the connector-level config fields that customers can set in the gateway + /// admin UI. Each test: + /// 1. Builds a with specific field combinations, + /// 2. Stubs GenerateOrderSSL + TrackOrder with a happy response, + /// 3. Invokes EnrollCertificateAsync, + /// 4. Reads the captured POST body from WireMock and asserts the shape. + /// + /// These tests pin the behaviour of the configurables documented in README.md → + /// "CA Configuration"; if a future refactor accidentally omits one of them from + /// the SSL order body, the corresponding test fails loudly. + /// + public class CERTInextClientRequestShapeTests : IDisposable + { + private readonly WireMockServer _server; + private readonly string _baseUrl; + + public CERTInextClientRequestShapeTests() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Urls[0]; + } + + public void Dispose() => _server.Stop(); + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private CERTInextClient BuildClient(CERTInextConfig config) + { + config.ApiUrl = _baseUrl; + return new CERTInextClient(config); + } + + private static CERTInextConfig MinimalConfig() => 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 CapturedOrderBody() + { + 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" + }; + + // ----------------------------------------------------------------------- + // OrganizationNumber → organizationDetails block + // ----------------------------------------------------------------------- + + [Fact] + public async Task OrganizationNumber_Set_EmitsPreVettedOrganizationDetails() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.OrganizationNumber = "9876543210"; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var orderDetails = CapturedOrderBody(); + orderDetails.TryGetProperty("organizationDetails", out var orgDetails).Should().BeTrue( + "organizationDetails must be present when OrganizationNumber is configured"); + orgDetails.GetProperty("preVetting").GetString().Should().Be("1", + "preVetting=1 declares the org as already vetted, bypassing the manual queue"); + orgDetails.GetProperty("organizationNumber").GetString().Should().Be("9876543210"); + } + + [Fact] + public async Task OrganizationNumber_Blank_OmitsOrganizationDetailsBlock() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.OrganizationNumber = string.Empty; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var orderDetails = CapturedOrderBody(); + orderDetails.TryGetProperty("organizationDetails", out _).Should().BeFalse( + "organizationDetails must be omitted when OrganizationNumber is unset (preserves legacy behavior)"); + } + + // ----------------------------------------------------------------------- + // GroupNumber → delegationInformation block + // ----------------------------------------------------------------------- + + [Fact] + public async Task GroupNumber_Set_EmitsDelegationInformation() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.GroupNumber = "2171775848"; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var orderDetails = CapturedOrderBody(); + orderDetails.TryGetProperty("delegationInformation", out var delegation).Should().BeTrue(); + delegation.GetProperty("groupNumber").GetString().Should().Be("2171775848"); + } + + [Fact] + public async Task GroupNumber_Blank_OmitsDelegationInformation() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.GroupNumber = string.Empty; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var orderDetails = CapturedOrderBody(); + orderDetails.TryGetProperty("delegationInformation", out _).Should().BeFalse(); + } + + // ----------------------------------------------------------------------- + // technicalPointOfContact — overrides + requestor fallback + // ----------------------------------------------------------------------- + + [Fact] + public async Task TechnicalContact_AllSet_EmitsExplicitValues() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.TechnicalContactName = "Jane Smith"; + cfg.TechnicalContactEmail = "tpc@example.com"; + cfg.TechnicalContactIsdCode = "44"; + cfg.TechnicalContactMobileNumber = "5559999999"; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var tpc = CapturedOrderBody().GetProperty("technicalPointOfContact"); + tpc.GetProperty("tpcName").GetString().Should().Be("Jane Smith"); + tpc.GetProperty("tpcEmail").GetString().Should().Be("tpc@example.com"); + tpc.GetProperty("tpcIsdCode").GetString().Should().Be("44"); + tpc.GetProperty("tpcMobileNumber").GetString().Should().Be("5559999999"); + } + + [Fact] + public async Task TechnicalContact_AllBlank_FallsBackToRequestorDefaults() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + // All TechnicalContact* unset → must fall back to Requestor* + cfg.TechnicalContactName = string.Empty; + cfg.TechnicalContactEmail = string.Empty; + cfg.TechnicalContactIsdCode = string.Empty; + cfg.TechnicalContactMobileNumber = string.Empty; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var tpc = CapturedOrderBody().GetProperty("technicalPointOfContact"); + tpc.GetProperty("tpcName").GetString().Should().Be(cfg.RequestorName); + tpc.GetProperty("tpcEmail").GetString().Should().Be(cfg.RequestorEmail); + tpc.GetProperty("tpcIsdCode").GetString().Should().Be(cfg.RequestorIsdCode); + tpc.GetProperty("tpcMobileNumber").GetString().Should().Be(cfg.RequestorMobileNumber); + } + + // ----------------------------------------------------------------------- + // SSL order body defaults — AccountingModel / EmailNotifications / + // SubscriptionAutoRenew / SubscriptionRenewCriteriaDays / + // SubscriptionValidityYears / AutoSecureWww + // ----------------------------------------------------------------------- + + [Fact] + public async Task SslBodyDefaults_AreEmitted_FromCustomConnectorValues() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.AccountingModel = "1"; + cfg.EmailNotifications = "1"; + cfg.SubscriptionValidityYears = "2"; + cfg.SubscriptionAutoRenew = "1"; + cfg.SubscriptionRenewCriteriaDays = "60"; + cfg.AutoSecureWww = "1"; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var od = CapturedOrderBody(); + od.GetProperty("accountingModel").GetString().Should().Be("1"); + od.GetProperty("emailNotifications").GetString().Should().Be("1"); + + var sub = od.GetProperty("subscriptionDetails"); + sub.GetProperty("validity").GetString().Should().Be("2"); + sub.GetProperty("autoRenew").GetString().Should().Be("1"); + sub.GetProperty("renewCriteria").GetString().Should().Be("60"); + + od.GetProperty("certificateInformation").GetProperty("autoSecureWWW").GetString().Should().Be("1"); + } + + [Fact] + public async Task SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + // Leave new fields at their CERTInextConfig defaults + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var od = CapturedOrderBody(); + od.GetProperty("accountingModel").GetString().Should().Be("2"); + od.GetProperty("emailNotifications").GetString().Should().Be("0"); + + var sub = od.GetProperty("subscriptionDetails"); + sub.GetProperty("validity").GetString().Should().Be("1"); + sub.GetProperty("autoRenew").GetString().Should().Be("0"); + sub.GetProperty("renewCriteria").GetString().Should().Be("30"); + + od.GetProperty("certificateInformation").GetProperty("autoSecureWWW").GetString().Should().Be("0"); + } + + // ----------------------------------------------------------------------- + // ValidityDays request-parameter still overrides the connector default + // ----------------------------------------------------------------------- + + [Fact] + public async Task ValidityDays_OnRequest_OverridesConnectorDefault() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.SubscriptionValidityYears = "1"; // connector default = 1 year + + var req = BasicEnrollRequest(); + req.ValidityDays = 730; // 2 years + + await BuildClient(cfg).EnrollCertificateAsync(req); + + CapturedOrderBody().GetProperty("subscriptionDetails") + .GetProperty("validity").GetString().Should().Be("2"); + } + } +} diff --git a/CERTInext.Tests/CERTInextClientTests.cs b/CERTInext.Tests/CERTInextClientTests.cs index 243d801..e473e89 100644 --- a/CERTInext.Tests/CERTInextClientTests.cs +++ b/CERTInext.Tests/CERTInextClientTests.cs @@ -689,7 +689,7 @@ public async Task OAuth_InjectsBearerToken_InAuthorizationHeader() pingRequest.RequestMessage.Headers.Should().ContainKey("Authorization", "OAuth mode must inject the Authorization header on outgoing requests"); - var authHeader = pingRequest.RequestMessage.Headers["Authorization"].FirstOrDefault(); + var authHeader = pingRequest.RequestMessage.Headers!["Authorization"].FirstOrDefault(); authHeader.Should().Be($"Bearer {expectedToken}", "the injected token must match the one returned by the token endpoint"); } @@ -713,7 +713,7 @@ public async Task OAuth_DoesNotInjectBearerToken_InAccessKeyMode() .First(e => e.RequestMessage.Path == "/ValidateCredentials"); // Authorization header must be absent in AccessKey mode - bool hasAuthHeader = pingRequest.RequestMessage.Headers.ContainsKey("Authorization"); + bool hasAuthHeader = pingRequest.RequestMessage.Headers!.ContainsKey("Authorization"); hasAuthHeader.Should().BeFalse( "AccessKey mode authenticates via the authKey field in the JSON body, not an HTTP header"); } @@ -744,5 +744,145 @@ public async Task ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500 pingCallCount.Should().Be(3, "ExecuteWithRetryAsync makes 3 total attempts on persistent 5xx errors"); } + + // --------------------------------------------------------------------------- + // GetDcvAsync — POST /GetDcv + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetDcvAsync_ReturnsToken_WhenServerRespondsOk() + { + const string token = "abc123token"; + _server + .Given(Request.Create().WithPath("/GetDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetDcvSuccessJson(token))); + + var client = BuildClient(); + + var result = await client.GetDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + result.Should().NotBeNull(); + result.DcvDetails.Should().NotBeNull(); + result.DcvDetails.Token.Should().Be(token); + _server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/GetDcv"); + } + + [Fact] + public async Task GetDcvAsync_Throws_WhenMetaStatusIsFailure() + { + _server + .Given(Request.Create().WithPath("/GetDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetDcvFailureJson("EMS-DCV-001", "DCV not available"))); + + var client = BuildClient(); + + Func act = () => client.GetDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + await act.Should().ThrowAsync() + .WithMessage("*GetDcv failed*"); + } + + [Fact] + public async Task GetDcvAsync_Throws_WhenServerReturns401() + { + _server + .Given(Request.Create().WithPath("/GetDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(401) + .WithBody(MockCertificateData.UnauthorizedJson())); + + var client = BuildClient(); + + Func act = () => client.GetDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + await act.Should().ThrowAsync() + .WithMessage("*Authentication failure*"); + } + + // --------------------------------------------------------------------------- + // VerifyDcvAsync — POST /VerifyDcv + // --------------------------------------------------------------------------- + + [Fact] + public async Task VerifyDcvAsync_Succeeds_WhenServerRespondsOk() + { + _server + .Given(Request.Create().WithPath("/VerifyDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.VerifyDcvSuccessJson())); + + var client = BuildClient(); + + // Should not throw + await client.VerifyDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + _server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/VerifyDcv"); + } + + [Fact] + public async Task VerifyDcvAsync_Throws_WhenMetaStatusIsFailure() + { + _server + .Given(Request.Create().WithPath("/VerifyDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.VerifyDcvFailureJson("EMS-DCV-002", "DNS record not found"))); + + var client = BuildClient(); + + Func act = () => client.VerifyDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + await act.Should().ThrowAsync() + .WithMessage("*DNS record not found*"); + } + + [Fact] + public async Task VerifyDcvAsync_Throws_WhenServerReturns401() + { + _server + .Given(Request.Create().WithPath("/VerifyDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(401) + .WithBody(MockCertificateData.UnauthorizedJson())); + + var client = BuildClient(); + + Func act = () => client.VerifyDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + await act.Should().ThrowAsync() + .WithMessage("*Authentication failure*"); + } + + [Fact] + public async Task VerifyDcvAsync_Throws_WhenServerReturns500() + { + _server + .Given(Request.Create().WithPath("/VerifyDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(500) + .WithBody(MockCertificateData.ServerErrorJson())); + + var client = BuildClient(); + + Func act = () => client.VerifyDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + await act.Should().ThrowAsync(); + } } } diff --git a/CERTInext.Tests/ExtractSerialFromPemTests.cs b/CERTInext.Tests/ExtractSerialFromPemTests.cs new file mode 100644 index 0000000..f8064dd --- /dev/null +++ b/CERTInext.Tests/ExtractSerialFromPemTests.cs @@ -0,0 +1,131 @@ +// Copyright 2024 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.Reflection; +using FluentAssertions; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for the private CERTInextCAPlugin.ExtractSerialFromPem + /// helper, which feeds the audit-log SerialNumber field. After the BouncyCastle + /// migration (replacing X509Certificate2.SerialNumber) we need to pin the + /// format invariants — particularly the leading-zero-byte case where the old BCL + /// behaviour and a naive BigInteger.ToString(16) diverge. + /// + public class ExtractSerialFromPemTests + { + private static string InvokeExtractSerialFromPem(string pem) + { + var method = typeof(CERTInextCAPlugin) + .GetMethod("ExtractSerialFromPem", BindingFlags.NonPublic | BindingFlags.Static); + method.Should().NotBeNull("test pins the format produced by ExtractSerialFromPem"); + return (string)method!.Invoke(null, new object[] { pem })!; + } + + /// + /// Generates a self-signed PEM cert with the specified serial number. Uses + /// BouncyCastle throughout — no BCL crypto — per the project's crypto policy. + /// + private static string GeneratePemWithSerial(BigInteger serial) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair keyPair = keyGen.GenerateKeyPair(); + + var subject = new X509Name("CN=test-serial-parity"); + var notBefore = DateTime.UtcNow.AddMinutes(-1); + var notAfter = notBefore.AddDays(1); + + var builder = new X509V3CertificateGenerator(); + builder.SetSerialNumber(serial); + builder.SetIssuerDN(subject); + builder.SetSubjectDN(subject); + builder.SetNotBefore(notBefore); + builder.SetNotAfter(notAfter); + builder.SetPublicKey(keyPair.Public); + + var signerFactory = new Asn1SignatureFactory("SHA256withRSA", keyPair.Private); + X509Certificate cert = builder.Generate(signerFactory); + + return "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(cert.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----"; + } + + [Fact] + public void ExtractSerialFromPem_PreservesLeadingZeroByte() + { + // Serial bytes 0x00 0x0A 0xFF 0xFF as an unsigned big-endian integer = 720895 + // X509Certificate2.SerialNumber would produce "0AFFFF" (sign byte stripped, + // remaining bytes hex-encoded, leading-zero NIBBLE preserved within byte boundary). + // A naive BigInteger.ToString(16) would produce "afff" (a 4-digit hex, dropping + // the leading zero nibble), which mis-correlates with Command's stored serial. + // + // Use a serial that has a leading-zero nibble in its first non-zero byte: + // 0x0A123456 → unsigned hex "0A123456" (8 nibbles). Anything that drops the + // leading zero produces "A123456" (7 nibbles). + var serial = new BigInteger("0A123456", 16); + string pem = GeneratePemWithSerial(serial); + + string result = InvokeExtractSerialFromPem(pem); + + result.Should().Be("0A123456", + "the serial must preserve the leading-zero nibble within its first byte " + + "so audit-log correlation against Command's stored serial succeeds"); + } + + [Fact] + public void ExtractSerialFromPem_NormalSerial_UppercaseHexNoLeadingZero() + { + // Plain mid-range serial; just confirms format is uppercase hex without separators. + var serial = new BigInteger("DEADBEEFCAFE", 16); + string pem = GeneratePemWithSerial(serial); + + string result = InvokeExtractSerialFromPem(pem); + + result.Should().Be("DEADBEEFCAFE"); + } + + [Fact] + public void ExtractSerialFromPem_LongSerial_AllBytesPreservedUppercase() + { + // 20-byte serial (the max CA/B Forum permits). Each byte must be uppercase + // hex, no separators, no leading-zero loss. + var serial = new BigInteger("01020304050607080910111213141516171819FA", 16); + string pem = GeneratePemWithSerial(serial); + + string result = InvokeExtractSerialFromPem(pem); + + result.Should().Be("01020304050607080910111213141516171819FA"); + } + + [Fact] + public void ExtractSerialFromPem_GarbageInput_ReturnsParseError() + { + // Robustness — audit-log path must never throw, only mark the failure. + InvokeExtractSerialFromPem("not a pem") + .Should().Be("(parse-error)"); + } + + [Fact] + public void ExtractSerialFromPem_EmptyBody_ReturnsEmptyPem() + { + InvokeExtractSerialFromPem("-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----") + .Should().Be("(empty-pem)"); + } + } +} diff --git a/CERTInext.Tests/FakeDomainValidator.cs b/CERTInext.Tests/FakeDomainValidator.cs new file mode 100644 index 0000000..6b42475 --- /dev/null +++ b/CERTInext.Tests/FakeDomainValidator.cs @@ -0,0 +1,69 @@ +// Copyright 2024 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 + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// In-memory stub that records staged and cleaned-up DNS TXT entries without + /// making real DNS calls. Configurable success/failure via init properties. + /// + internal sealed class FakeDomainValidator : IDomainValidator + { + /// All (key, value) pairs passed to . + public List<(string key, string value)> StagedRecords { get; } = new(); + + /// All keys passed to . + public List CleanedUpKeys { get; } = new(); + + /// When false, returns a failure result. + public bool StageSucceeds { get; init; } = true; + + /// Error message returned when is false. + public string StageError { get; init; } = "Stage failed (test stub)"; + + public void Initialize(IDomainValidatorConfigProvider configProvider) { } + + public Task StageValidation(string key, string value, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + StagedRecords.Add((key, value)); + return Task.FromResult(new DomainValidationResult + { + Success = StageSucceeds, + ErrorMessage = StageSucceeds ? null : StageError + }); + } + + public Task CleanupValidation(string key, CancellationToken cancellationToken) + { + CleanedUpKeys.Add(key); + return Task.FromResult(new DomainValidationResult { Success = true }); + } + + public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask; + public Dictionary GetDomainValidatorAnnotations() => new(); + public string GetValidationType() => "dns-01"; + } + + /// + /// Factory that returns a single pre-configured for every + /// domain. Pass null as the validator to simulate "no DNS provider configured". + /// + internal sealed class FakeDomainValidatorFactory : IDomainValidatorFactory + { + private readonly IDomainValidator _validator; + + public FakeDomainValidatorFactory(IDomainValidator validator = null) => _validator = validator; + + public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator; + + /// The validator this factory returns; exposed for assertions in tests. + public IDomainValidator PrimaryValidator => _validator; + } +} diff --git a/CERTInext.Tests/MockCertificateData.cs b/CERTInext.Tests/MockCertificateData.cs index 04903b0..ee6644b 100644 --- a/CERTInext.Tests/MockCertificateData.cs +++ b/CERTInext.Tests/MockCertificateData.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Text.Json; using Keyfactor.Extensions.CAPlugin.CERTInext.API; namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests @@ -312,6 +313,20 @@ public static LegacyGetCertificateResponse IssuedCertRecord(string id = null) => Csr = FakeCsrPem }; + /// + /// A LegacyGetCertificateResponse representing an order that is past DCV verification + /// but still has CERTInext-side issuance in progress. Status maps to + /// so post-DCV polling logic continues. + /// + public static LegacyGetCertificateResponse PendingCertRecord(string id = null) => + new LegacyGetCertificateResponse + { + Id = id ?? CertId1, + Status = "pending_approval", // → EXTERNALVALIDATION via StatusMapper + Certificate = null, + SerialNumber = null + }; + public static LegacyGetCertificateResponse RevokedCertRecord(string id = null) => new LegacyGetCertificateResponse { @@ -334,6 +349,125 @@ public static LegacyGetCertificateResponse RevokedCertRecord(string id = null) = public static string OAuth2TokenJson(int expiresIn = 3600) => $@"{{""access_token"":""fake-bearer-token-abc123"",""token_type"":""Bearer"",""expires_in"":{expiresIn}}}"; + // ----------------------------------------------------------------------- + // DCV (domain control validation) + // ----------------------------------------------------------------------- + + public const string DcvOrderId = "ORD-DCV-001"; + public const string DcvDomain = "example.com"; + public const string DcvToken = "abc123dcvtoken"; + + /// + /// Returns a with one pending DNS-TXT domain entry, + /// ready for Moq setups that exercise the DCV orchestration path. + /// + public static TrackOrderResponse DcvPendingTrackResponse( + string orderNumber = DcvOrderId, + string domain = DcvDomain) + { + var detail = JsonSerializer.SerializeToElement(new DomainVerificationDetail + { + DcvMethod = Constants.Dcv.MethodDnsTxt, + DcvStatus = Constants.Dcv.StatusPending, + Status = "1" + }); + + return new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusPending, + RawDomainEntries = new Dictionary { [domain] = detail } + } + } + }; + } + + public static TrackOrderResponse DcvVerifiedTrackResponse( + string orderNumber = DcvOrderId, + string domain = DcvDomain) + { + var detail = JsonSerializer.SerializeToElement(new DomainVerificationDetail + { + DcvMethod = Constants.Dcv.MethodDnsTxt, + DcvStatus = Constants.Dcv.StatusValidated, + Status = "1" + }); + + return new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "2", + CertificateStatusId = "24", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated, + RawDomainEntries = new Dictionary { [domain] = detail } + } + } + }; + } + + /// + /// Returns a whose order is already in a terminal + /// issued state — DCV should be skipped entirely when this is returned. + /// + public static TrackOrderResponse AlreadyIssuedTrackResponse(string orderNumber = CertId1) => + new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "4", + CertificateStatusId = "9", // CertificateGenerated — maps to GENERATED + } + }; + + /// + /// Returns a containing the TXT token for Moq setups. + /// + public static GetDcvResponse DcvTokenResponse(string token = DcvToken) => + new GetDcvResponse + { + DcvDetails = new DcvResponseDetails { Token = token } + }; + + /// + /// POST /GetDcv — success response containing the TXT record token for DNS DCV. + /// + public static string GetDcvSuccessJson(string token = "abc123token") => + $@"{{ + ""meta"":{SuccessMetaJson()}, + ""dcvDetails"":{{ + ""token"":""{token}"", + ""fileName"":null, + ""fileContent"":null, + ""dcvEmails"":null + }} +}}"; + + /// + /// POST /GetDcv — failure response (bad order or unsupported dcvMethod). + /// + public static string GetDcvFailureJson(string code = "EMS-DCV-001", string msg = "DCV not available for this order") => + $@"{{""meta"":{FailureMetaJson(code, msg)}}}"; + + /// + /// POST /VerifyDcv — success response (meta only, no additional payload). + /// + public static string VerifyDcvSuccessJson() => + $@"{{""meta"":{SuccessMetaJson()}}}"; + + /// + /// POST /VerifyDcv — failure response (TXT record not found). + /// + public static string VerifyDcvFailureJson(string code = "EMS-DCV-002", string msg = "DNS record not found") => + $@"{{""meta"":{FailureMetaJson(code, msg)}}}"; + // ----------------------------------------------------------------------- // Error responses // ----------------------------------------------------------------------- diff --git a/CERTInext.Tests/RateLimitRetryTests.cs b/CERTInext.Tests/RateLimitRetryTests.cs new file mode 100644 index 0000000..7750073 --- /dev/null +++ b/CERTInext.Tests/RateLimitRetryTests.cs @@ -0,0 +1,64 @@ +// Copyright 2024 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 +{ + /// + /// Pure unit tests for the rate-limit-retry helpers in . + /// Behavioral / end-to-end coverage of the retry loop itself lives in the WireMock + /// tests; here we pin the predicate and the backoff schedule. + /// + public class RateLimitRetryTests + { + [Theory] + [InlineData("Inactive Account User.", true)] // exact form from sandbox + [InlineData("inactive account user.", true)] // case-insensitive + [InlineData("INACTIVE ACCOUNT USER", true)] // case + missing period + [InlineData("Some preamble: Inactive Account User. Tail", true)] // embedded substring + [InlineData("Active account user.", false)] // wrong polarity + [InlineData("Account is inactive", false)] // similar phrase, wrong wording + [InlineData("EMS-956 Invalid Request for this API.", false)] // unrelated error + [InlineData("", false)] + [InlineData(null, false)] + public void IsRateLimitSurface_DetectsDocumentedPhraseOnly(string errorMessage, bool expected) + { + CERTInextClient.IsRateLimitSurface(errorMessage).Should().Be(expected); + } + + [Theory] + [InlineData(1, 0.75, 1.25)] // base = 1s, jittered ±25% ⇒ [0.75, 1.25] + [InlineData(2, 1.5, 2.5)] // 2s × jitter + [InlineData(3, 3.0, 5.0)] // 4s × jitter + [InlineData(4, 6.0, 10.0)] // 8s × jitter + [InlineData(5, 12.0, 20.0)] // 16s × jitter + public void ComputeRateLimitBackoffSeconds_ProducesExpectedRange(int attempt, double min, double max) + { + // Run several samples so jitter is exercised; every sample must fall inside + // the documented exponential ± 25% jitter window. + for (int i = 0; i < 50; i++) + { + double waitSeconds = CERTInextClient.ComputeRateLimitBackoffSeconds(attempt); + waitSeconds.Should().BeInRange(min, max, + $"attempt {attempt} sample {i} must fall inside the documented backoff window"); + } + } + + [Fact] + public void ComputeRateLimitBackoffSeconds_ClampsAttemptsBelowOneToOne() + { + // Defensive: passing 0 or negative shouldn't produce zero / negative delay. + CERTInextClient.ComputeRateLimitBackoffSeconds(0) + .Should().BeInRange(0.75, 1.25); + CERTInextClient.ComputeRateLimitBackoffSeconds(-3) + .Should().BeInRange(0.75, 1.25); + } + } +} diff --git a/CERTInext.Tests/RedactCredentialsTests.cs b/CERTInext.Tests/RedactCredentialsTests.cs new file mode 100644 index 0000000..fad3e46 --- /dev/null +++ b/CERTInext.Tests/RedactCredentialsTests.cs @@ -0,0 +1,108 @@ +// Copyright 2024 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 +{ + /// + /// Pins the credential-scrubbing pass that runs on + /// every response body before truncation. The CERTInext request meta block + /// includes an authKey SHA-256 digest that is itself a replayable + /// credential under SOX (anyone with one valid (ts, txn, authKey) triple + /// can replay until the timestamp window expires). These tests pin that the + /// scrubber catches both the documented-as-sent fields (authKey) and + /// adjacent credential field names that *could* end up on the wire if a future + /// code path wires them in (client_secret, accessKey, password). + /// See the audit report for commit aab1847. + /// + public class RedactCredentialsTests + { + [Theory] + [InlineData( + "{\"meta\":{\"authKey\":\"deadbeefdeadbeefdeadbeef\",\"ts\":\"2026\"}}", + "{\"meta\":{\"authKey\":\"***REDACTED***\",\"ts\":\"2026\"}}")] + [InlineData( + "{\"client_secret\":\"super-secret-12345\"}", + "{\"client_secret\":\"***REDACTED***\"}")] + [InlineData( + "{\"apiKey\":\"raw-access-key-value\",\"other\":\"keep\"}", + "{\"apiKey\":\"***REDACTED***\",\"other\":\"keep\"}")] + [InlineData( + "{\"accessKey\":\"xxx\",\"password\":\"yyy\"}", + "{\"accessKey\":\"***REDACTED***\",\"password\":\"***REDACTED***\"}")] + public void RedactCredentials_ScrubsJsonCredentialFields(string input, string expected) + { + CERTInextClient.RedactCredentials(input).Should().Be(expected); + } + + [Theory] + [InlineData( + "grant_type=client_credentials&client_secret=super-secret-12345&client_id=public-id", + "grant_type=client_credentials&client_secret=***REDACTED***&client_id=public-id")] + [InlineData( + "authKey=abc123def456", + "authKey=***REDACTED***")] + public void RedactCredentials_ScrubsFormUrlEncodedCredentialFields(string input, string expected) + { + CERTInextClient.RedactCredentials(input).Should().Be(expected); + } + + [Fact] + public void RedactCredentials_ScrubsAuthorizationHeaderLines() + { + string input = + "POST /token HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Authorization: Bearer ya29.abcdef-secret-token\r\n" + + "Content-Type: application/json\r\n"; + string output = CERTInextClient.RedactCredentials(input); + output.Should().Contain("Authorization: ***REDACTED***"); + output.Should().NotContain("ya29.abcdef-secret-token"); + output.Should().Contain("Host: example.com"); + output.Should().Contain("Content-Type: application/json"); + } + + [Fact] + public void RedactCredentials_PreservesNonCredentialFields() + { + string input = "{\"meta\":{\"ts\":\"2026-05-22\",\"txn\":\"12345\",\"errorMessage\":\"Inactive Account User.\"}}"; + string output = CERTInextClient.RedactCredentials(input); + output.Should().Be(input, "non-credential fields must pass through unchanged"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void RedactCredentials_HandlesNullAndEmpty(string input) + { + // Should not throw and should return the input unchanged (or empty for null). + // The current implementation returns the input as-is for these edge cases. + CERTInextClient.RedactCredentials(input).Should().Be(input); + } + + [Fact] + public void RedactCredentials_CaseInsensitiveFieldNameMatch() + { + // CERTInext historically uses mixed casing (`AuthKey`, `apiKey`, etc.) + // depending on the endpoint. Make sure none slip past the scrubber. + string input = "{\"AuthKey\":\"abc\",\"APIKEY\":\"def\",\"ClientSecret\":\"xyz\"}"; + + string output = CERTInextClient.RedactCredentials(input); + + // ClientSecret isn't currently in the redaction list (only client_secret is), + // and that's intentional — the JSON convention CERTInext uses is the + // snake_case form on the OAuth token endpoint. If we ever observe + // CamelCase variants on the wire, extend the regex. Documented here so + // a future regression review catches the gap. + output.Should().Contain("\"AuthKey\":\"***REDACTED***\""); + output.Should().Contain("\"APIKEY\":\"***REDACTED***\""); + } + } +} diff --git a/CERTInext.Tests/TESTING.md b/CERTInext.Tests/TESTING.md index b703d61..e56c35a 100644 --- a/CERTInext.Tests/TESTING.md +++ b/CERTInext.Tests/TESTING.md @@ -1,27 +1,41 @@ -# CERTInext CA Plugin — Test Suite Reference +# CERTInext CA Plugin — Unit Test Suite Reference ## Overview -There are two test classes in this project, each targeting a different layer of the plugin: +The `CERTInext.Tests` project contains unit and contract tests for the CERTInext AnyCA Gateway +REST plugin. No external services are required — all HTTP I/O is handled in-process by WireMock.Net +or replaced by Moq strict mocks. -**`CERTInextClientTests`** tests the HTTP client (`CERTInextClient`) in isolation. It uses [WireMock.Net](https://github.com/WireMock-Net/WireMock.Net) to start a real in-process HTTP server on a random port, then directs the client at that server. RestSharp makes actual HTTP calls, so JSON serialization, request routing, header construction, OAuth2 token fetching, and pagination logic are all exercised end-to-end against real network I/O (loopback only). +The project is split into several focused test classes: -**`CERTInextCAPluginTests`** tests the `CERTInextCAPlugin` class — the Keyfactor `IAnyCAPlugin` implementation. It replaces `ICERTInextClient` with a [Moq](https://github.com/moq/moq4) strict mock so no network calls are made. The focus is on plugin-level logic: argument validation, status mapping, enrollment type routing, revocation reason translation, and synchronization behavior. +| 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) | -The split keeps concerns separate. If a test fails in `CERTInextClientTests`, the bug is in HTTP transport or serialization. If it fails in `CERTInextCAPluginTests`, the bug is in plugin logic. +If a test fails in `CERTInextClientTests` or `CERTInextClientRequestShapeTests`, the bug is in +HTTP transport or request serialisation. If it fails in `CERTInextCAPluginTests` or +`CERTInextCAPluginCoverageTests`, the bug is in plugin logic. --- ## Running the Tests **Prerequisites:** -- .NET SDK 8.0 or later +- .NET 8 or .NET 10 SDK - NuGet packages restored (`dotnet restore`) -- No external services required — WireMock runs in-process +- No external services required **Run all tests:** ```bash -dotnet test +dotnet test CERTInext.Tests/ ``` **Run a single test class:** @@ -35,259 +49,340 @@ dotnet test --filter "FullyQualifiedName~CERTInextCAPluginTests" dotnet test --filter "DisplayName~OAuth2_TokenIsCached" ``` -Each `CERTInextClientTests` instance starts a fresh `WireMockServer` in its constructor and stops it in `Dispose()`, so tests are isolated and can run in parallel without port conflicts. +Each `CERTInextClientTests` instance starts a fresh `WireMockServer` in its constructor and +stops it in `Dispose()`, so tests are isolated and can run in parallel without port conflicts. + +--- + +## Authentication model + +The real CERTInext 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 + +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 --- ## CERTInextClientTests -The test class implements `IDisposable`. A `WireMockServer` is started on a random available port in the constructor. All tests build a `CERTInextClient` pointed at `_server.Urls[0]`. +The test class implements `IDisposable`. A `WireMockServer` is started on a random available port +in the constructor. All tests build a `CERTInextClient` pointed at `_server.Urls[0]`. Two helper methods build clients: -- `BuildClient(authMode, apiKey)` — builds an ApiKey-authenticated client (default: `authMode="ApiKey"`, `apiKey="test-key"`) -- `BuildOAuthClient(tokenUrl)` — builds an OAuth2 client with `client_id="my-client"` and `client_secret="my-secret"` +- `BuildClient(authMode, apiKey)` — builds an AccessKey-authenticated client + (defaults: `authMode="AccessKey"`, `apiKey="test-key"`, `accountNumber="12345"`) +- `BuildOAuthClient(tokenUrl)` — builds an OAuth client with `client_id="my-client"`, + `client_secret="my-secret"` -### PingAsync +### PingAsync — POST /ValidateCredentials | Test | Stub | Assertion | |------|------|-----------| -| `PingAsync_ReturnsHealthy_WhenServerRespondsOk` | `GET /api/v1/health` → 200, `{"status":"ok","version":"2.1.0"}` | Does not throw; WireMock log contains a request to `/api/v1/health` | -| `PingAsync_Throws_When500Returned` | `GET /api/v1/health` → 500, server error body | Throws an `Exception` with message containing `"health check failed"` | +| `PingAsync_ReturnsHealthy_WhenServerRespondsOk` | `POST /ValidateCredentials` → 200, success meta | Does not throw; WireMock log contains a request to `/ValidateCredentials` | +| `PingAsync_Throws_When500Returned` | `POST /ValidateCredentials` → 500, server error body | Throws `Exception` with message containing `"health check failed"` | +| `PingAsync_Throws_WhenMetaStatusIsFailure` | `POST /ValidateCredentials` → 200, failure meta (`EMS-001`, `"Invalid credentials"`) | Throws `Exception` with message containing `"credential validation failed"` | -### API Key Authentication +### OAuth2 Token Fetch, Caching, and Injection | Test | Stub | Assertion | |------|------|-----------| -| `PingAsync_SendsApiKeyHeader_WhenAuthModeIsApiKey` | `GET /api/v1/health` matched only when header `X-API-Key: super-secret-key` is present → 200 | WireMock records exactly one matched request, confirming the header was sent with the correct value | - -This test verifies the header matching at the WireMock level: if the client sends the wrong header name or value, WireMock finds no matching stub and the request fails. +| `OAuth2_FetchesToken_BeforeFirstApiCall` | `POST /oauth/token` → token JSON; `POST /ValidateCredentials` → 200 | Log contains both `/oauth/token` and `/ValidateCredentials` | +| `OAuth2_TokenIsCached_SecondCallDoesNotRefetch` | Same stubs | `PingAsync` called twice; `/oauth/token` appears exactly once; `/ValidateCredentials` appears twice | +| `OAuth_InjectsBearerToken_InAuthorizationHeader` | Token endpoint → `fake-bearer-token-abc123`; `/ValidateCredentials` → 200 | WireMock log entry for `/ValidateCredentials` carries `Authorization: Bearer fake-bearer-token-abc123` | +| `OAuth_DoesNotInjectBearerToken_InAccessKeyMode` | `/ValidateCredentials` → 200 | WireMock log entry has no `Authorization` header | -### OAuth2 Token Fetch, Caching, and Header Injection +### Retry logic | Test | Stub | Assertion | |------|------|-----------| -| `OAuth2_FetchesToken_BeforeFirstApiCall` | `POST /oauth/token` → 200, token JSON; `POST /ValidateCredentials` → 200 | Log entries contain both `/oauth/token` and `/ValidateCredentials` | -| `OAuth2_TokenIsCached_SecondCallDoesNotRefetch` | Same as above | `PingAsync` called twice; `/oauth/token` appears exactly once in log; `/ValidateCredentials` appears twice | -| `OAuth_InjectsBearerToken_InAuthorizationHeader` | Token endpoint → `fake-bearer-token-abc123`; ValidateCredentials → 200 | WireMock log for `/ValidateCredentials` contains header `Authorization: Bearer fake-bearer-token-abc123` | -| `OAuth_DoesNotInjectBearerToken_InAccessKeyMode` | `/ValidateCredentials` → 200 | WireMock log entry for `/ValidateCredentials` has no `Authorization` header | +| `ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500` | `/ValidateCredentials` always → 500 | `PingAsync` throws; WireMock log has exactly 3 requests (3 total attempts, 4xx are not retried) | -The `OAuth_InjectsBearerToken_InAuthorizationHeader` test is the P1-A regression test. Before the fix, `CERTInextClient` stored the token in a `[ThreadStatic]` field that was never injected into actual requests. The fix replaces this with a `CERTInextOAuthAuthenticator : AuthenticatorBase` subclass that injects the header per-request via RestSharp's authenticator interface. - -### Retry Logic +### EnrollCertificateAsync — POST /GenerateOrderSSL | Test | Stub | Assertion | |------|------|-----------| -| `ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500` | `/ValidateCredentials` always → 500 | `PingAsync` throws; WireMock log contains exactly 3 requests to `/ValidateCredentials` | - -`ExecuteWithRetryAsync` retries on HTTP 5xx (and network-level failures) for up to `maxAttempts=3` total attempts. 4xx responses are not retried. +| `EnrollCertificateAsync_ReturnsCertificate_WhenServerIssues` | `POST /GenerateOrderSSL` → 200, success meta + `orderDetails.orderNumber="ORD-AAA-111"` | Result not null; `OrderNumber == "ORD-AAA-111"` | +| `EnrollCertificateAsync_ReturnsPending_WhenServerReturnsPendingApproval` | `POST /GenerateOrderSSL` → 200, pending response | Status maps to pending | +| `EnrollCertificateAsync_Throws_WhenGenerateOrderFails` | `POST /GenerateOrderSSL` → 200, failure meta (EMS-918) | Throws `Exception` containing the API error message | +| `EnrollCertificateAsync_Throws_When5xxReturned` | `POST /GenerateOrderSSL` → 500 | Throws `Exception` | +| `EnrollCertificateAsync_Throws_When401Returned` | `POST /GenerateOrderSSL` → 401 | Throws `Exception` | -### EnrollCertificateAsync +### GetCertificateAsync — POST /GetCertificate | Test | Stub | Assertion | |------|------|-----------| -| `EnrollCertificateAsync_ReturnsCertificate_WhenServerIssues` | `POST /api/v1/certificates` → 200, enroll response with `status="issued"`, cert PEM, `id=CertId1` | Result is not null; `Id == CertId1`; `Status == "issued"`; `Certificate` contains `"BEGIN CERTIFICATE"` | -| `EnrollCertificateAsync_ReturnsPending_WhenServerReturnsPendingApproval` | `POST /api/v1/certificates` → 200, `{"status":"pending_approval","certificate":null,...}` | `Status == "pending_approval"`; `Certificate` is null | -| `EnrollCertificateAsync_Throws_When4xxReturned` | `POST /api/v1/certificates` → 400, `{"error":"BAD_REQUEST","message":"Invalid CSR."}` | Throws `Exception` with message containing `"Invalid CSR"` | -| `EnrollCertificateAsync_Throws_When5xxReturned` | `POST /api/v1/certificates` → 500, server error body | Throws `Exception` (any type) | -| `EnrollCertificateAsync_Throws_When401Returned` | `POST /api/v1/certificates` → 401, unauthorized body | Throws `Exception` (any type) | +| `GetCertificateAsync_ReturnsCertificate_WhenFound` | `POST /GetCertificate` → 200, PEM in `certificateDetails.endEntityCertificate` | PEM contains `"BEGIN CERTIFICATE"`; serial `"0A1B2C3D4E5F"` | +| `GetCertificateAsync_ThrowsKeyNotFound_WhenOrderNotFound` | `POST /GetCertificate` → 200, failure meta (EMS-not-found) | Throws `KeyNotFoundException` | -### GetCertificateAsync +### RevokeCertificateAsync — POST /RevokeOrder | Test | Stub | Assertion | |------|------|-----------| -| `GetCertificateAsync_ReturnsCertificate_WhenFound` | `GET /api/v1/certificates/{CertId1}` → 200, full certificate JSON | Result is not null; `Id == CertId1`; `Status == "issued"`; `Certificate` contains `"BEGIN CERTIFICATE"` | -| `GetCertificateAsync_ThrowsKeyNotFound_When404Returned` | `GET /api/v1/certificates/nonexistent-id` → 404, not-found error body | Throws `KeyNotFoundException` | +| `RevokeCertificateAsync_Succeeds_When200Returned` | `POST /RevokeOrder` → 200, success meta | Does not throw | +| `RevokeCertificateAsync_Throws_WhenServerReturnsFailure` | `POST /RevokeOrder` → 200, failure meta | Throws `Exception` | -### RevokeCertificateAsync +### RenewCertificateAsync — POST /GenerateOrderSSL + +CERTInext has no dedicated renewal endpoint. `RenewCertificateAsync` submits a new +`GenerateOrderSSL` order. The test verifies that the correct endpoint and body are used. | Test | Stub | Assertion | |------|------|-----------| -| `RevokeCertificateAsync_Succeeds_When200Returned` | `POST /api/v1/certificates/{CertId1}/revoke` → 200, `{"success":true,...}` | Does not throw | -| `RevokeCertificateAsync_Throws_When4xxReturned` | `POST /api/v1/certificates/{CertId1}/revoke` → 409, `{"error":"ALREADY_REVOKED",...}` | Throws `Exception` with message containing `"revoke certificate"` | +| `RenewCertificateAsync_ReturnsNewCertificate_OnSuccess` | `POST /GenerateOrderSSL` → 200, success with new order number | New order number returned | + +### ListCertificatesAsync — POST /GetOrderReport (paginated) -### RenewCertificateAsync +`ListCertificatesAsync` is an `IAsyncEnumerable` that paginates +`GetOrderReport`. Pagination stops when the returned page is empty or all pages are fetched. | Test | Stub | Assertion | |------|------|-----------| -| `RenewCertificateAsync_ReturnsNewCertificate_OnSuccess` | `POST /api/v1/certificates/{CertId1}/renew` → 200, renew response with `id="cert-renewed-001"` | `Id == "cert-renewed-001"`; `Status == "issued"`; `Certificate` contains `"BEGIN CERTIFICATE"` | - -### ListCertificatesAsync +| `ListCertificatesAsync_ReturnsSinglePage_WhenOnlyOnePage` | `POST /GetOrderReport` → single-page with `ORD-AAA-111` | Enumeration yields exactly 1 item | +| `ListCertificatesAsync_IteratesMultiplePages` | Two pages: page 1 (`ORD-AAA-111`), page 2 (`ORD-BBB-222`) | Enumeration yields 2 items; both order numbers present | +| `ListCertificatesAsync_StopsWhenEmptyPageReturned` | `POST /GetOrderReport` → empty `ordersArray` | Enumeration yields 0 items | +| `ListCertificatesAsync_RespectsIssuedAfterFilter` | Any request with `issuedAfter` parameter → single-page | Enumeration yields 1 item; `issuedAfter` key present in the request log | -`ListCertificatesAsync` is an `IAsyncEnumerable` that pages through results using a `page` query parameter, stopping when the returned page is empty or the last page has been fetched. +### GetProfilesAsync — POST /GetProductDetails | Test | Stub | Assertion | |------|------|-----------| -| `ListCertificatesAsync_ReturnsSinglePage_WhenOnlyOnePage` | `GET /api/v1/certificates?page=1` → 200, single-page list with one cert (`CertId1`) | Enumeration yields exactly 1 item with `Id == CertId1` | -| `ListCertificatesAsync_IteratesMultiplePages` | `GET /api/v1/certificates?page=1` → page 1 of 2 (`CertId1`); `GET /api/v1/certificates?page=2` → page 2 of 2 (`CertId2`) | Enumeration yields 2 items; both `CertId1` and `CertId2` are present | -| `ListCertificatesAsync_StopsWhenEmptyPageReturned` | `GET /api/v1/certificates?page=1` → 200, `{"data":[],"pagination":{"total":0,...}}` | Enumeration yields 0 items | -| `ListCertificatesAsync_RespectsIssuedAfterFilter` | Any `GET /api/v1/certificates` request that includes an `issuedAfter` query parameter → 200, single-page list | Enumeration yields 1 item; WireMock log entry for the first request has an `issuedAfter` key in its query string | +| `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 | -### GetProfilesAsync +### DCV endpoints | Test | Stub | Assertion | |------|------|-----------| -| `GetProfilesAsync_ReturnsProfiles_WhenServerResponds` | `GET /api/v1/profiles` → 200, two-profile JSON (`ProfileIdTls`, `ProfileIdClient`, both active) | Result has 2 items; both profile IDs present; all have `Active == true` | -| `GetProfilesAsync_ReturnsEmptyList_WhenDataIsEmpty` | `GET /api/v1/profiles` → 200, `{"data":[]}` | Result is empty | +| `GetDcvAsync_ReturnsToken_WhenServerRespondsOk` | `POST /GetDcv` → 200, `dcvDetails.token="abc123token"` | Returns token string | +| `GetDcvAsync_Throws_WhenMetaStatusIsFailure` | `POST /GetDcv` → 200, failure meta | Throws `Exception` | +| `GetDcvAsync_Throws_WhenServerReturns401` | `POST /GetDcv` → 401 | Throws `Exception` | +| `VerifyDcvAsync_Succeeds_WhenServerRespondsOk` | `POST /VerifyDcv` → 200, success meta | Does not throw | +| `VerifyDcvAsync_Throws_WhenMetaStatusIsFailure` | `POST /VerifyDcv` → 200, failure meta | Throws `Exception` | +| `VerifyDcvAsync_Throws_WhenServerReturns401` | `POST /VerifyDcv` → 401 | Throws `Exception` | +| `VerifyDcvAsync_Throws_WhenServerReturns500` | `POST /VerifyDcv` → 500 | Throws `Exception` | + +--- + +## CERTInextClientRequestShapeTests + +Uses WireMock to verify that the `GenerateOrderSSL` request body includes or omits optional +blocks depending on connector configuration. + +| Test | Assertion | +|------|-----------| +| `OrganizationNumber_Set_EmitsPreVettedOrganizationDetails` | Body includes `organizationDetails.preVetting="1"` and the configured `organizationNumber` | +| `OrganizationNumber_Blank_OmitsOrganizationDetailsBlock` | Body omits `organizationDetails` entirely | +| `GroupNumber_Set_EmitsDelegationInformation` | Body includes `delegationInformation.groupNumber` | +| `GroupNumber_Blank_OmitsDelegationInformation` | Body omits `delegationInformation` | +| `TechnicalContact_AllSet_EmitsExplicitValues` | Body includes `technicalPointOfContact` with the configured values | +| `TechnicalContact_AllBlank_FallsBackToRequestorDefaults` | Body includes `technicalPointOfContact` fields derived from `RequestorName`/`RequestorEmail` | +| `SslBodyDefaults_AreEmitted_FromCustomConnectorValues` | Custom connector-level defaults appear in the order body | +| `SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched` | Default values are emitted without throwing when optional config fields are omitted | +| `ValidityDays_OnRequest_OverridesConnectorDefault` | `ValidityDays` template parameter overrides the connector `SubscriptionValidityYears` | --- ## CERTInextCAPluginTests -The plugin is constructed by passing an `ICERTInextClient` mock directly: `new CERTInextCAPlugin(client)`. Moq is configured with `MockBehavior.Strict`, so any call to a method that has no setup will throw, making unexpected client calls immediately visible. +The plugin is constructed with `new CERTInextCAPlugin(client)` where `client` is a Moq strict +mock of `ICERTInextClient`. Any call to an unset-up method throws immediately, making unexpected +client calls visible. -Two local helpers are used across tests: -- `MakeProductInfo(profileId, extras)` — builds an `EnrollmentProductInfo` with `ProductID` and a `ProductParameters` dictionary containing `"ProfileId"` -- `AsyncEnum(items)` — wraps a `List` as an `IAsyncEnumerable` for use in `ListCertificatesAsync` mock setups +Two helpers are used across tests: +- `MakeProductInfo(profileId, extras)` — builds an `EnrollmentProductInfo` with `ProfileId` in + `ProductParameters` +- `AsyncEnum(items)` — wraps a list as `IAsyncEnumerable` ### Ping | Test | Mock setup | Assertion | |------|-----------|-----------| | `Ping_Succeeds_WhenClientPingAsyncDoesNotThrow` | `PingAsync` returns `Task.CompletedTask` | Does not throw; `PingAsync` called exactly once | -| `Ping_Rethrows_WhenClientPingThrows` | `PingAsync` throws `Exception("Connection refused")` | Throws `Exception` with message matching `"*CERTInext*Connection refused*"` — verifies the plugin wraps the error with context | -| `Ping_SkipsConnectivityTest_WhenConnectorIsDisabled` | Strict mock with no setups; `CERTInextConfig.Enabled = false` | Does not throw; no client method is called (verified via `VerifyNoOtherCalls()`) | +| `Ping_Rethrows_WhenClientPingThrows` | `PingAsync` throws `Exception("Connection refused")` | Throws `Exception` with message matching `"*CERTInext*Connection refused*"` | +| `Ping_SkipsConnectivityTest_WhenConnectorIsDisabled` | Strict mock, no setups; `CERTInextConfig.Enabled = false` | Does not throw; no client method called (verified via `VerifyNoOtherCalls()`) | ### GetProductIds | Test | Mock setup | Assertion | |------|-----------|-----------| -| `GetProductIds_ReturnsStaticProductList` | No mock calls expected (strict mock verifies this) | Returns 10 items; contains `DV SSL`, `OV SSL`, `EV SSL`; no client method is called | - -`GetProductIds()` returns a hardcoded static list rather than making a live API call. This is intentional: `IAnyCAPlugin.GetProductIds()` is synchronous (calling `GetAwaiter().GetResult()` risks deadlock), and the Keyfactor integration-manifest tooling requires a known list at reflection time. The `VerifyNoOtherCalls()` assertion on the strict mock confirms no API call is made. +| `GetProductIds_ReturnsStaticProductList` | No mock calls expected | Returns 10 items including `DV SSL`, `OV SSL`, `EV SSL`; no client method called | -### ValidateCAConnectionInfo - -The plugin validates the connection info dictionary before any API calls are made. - -| Test | Input | Assertion | -|------|-------|-----------| -| `ValidateCAConnectionInfo_Throws_WhenApiUrlMissing` | `AuthMode="ApiKey"`, `ApiKey` set, no `ApiUrl` | Throws `AnyCAValidationException` with message matching `"*ApiUrl*required*"` | -| `ValidateCAConnectionInfo_Throws_WhenApiUrlIsNotUri` | `ApiUrl="not-a-url"` | Throws `AnyCAValidationException` with message matching `"*valid absolute URI*"` | -| `ValidateCAConnectionInfo_Throws_WhenApiKeyMissingForApiKeyMode` | `ApiUrl` set, `AuthMode="ApiKey"`, no `ApiKey` | Throws `AnyCAValidationException` with message matching `"*ApiKey*required*"` | -| `ValidateCAConnectionInfo_Throws_WhenBasicCredentialsMissing` | `ApiUrl` set, `AuthMode="Basic"`, no `Username` or `Password` | Throws `AnyCAValidationException` with message matching `"*Username*required*"` | -| `ValidateCAConnectionInfo_Throws_WhenOAuth2FieldsMissing` | `ApiUrl` set, `AuthMode="OAuth2"`, no token URL, client ID, or secret | Throws `AnyCAValidationException` with message matching `"*OAuth2TokenUrl*required*"` | -| `ValidateCAConnectionInfo_Throws_WhenAuthModeIsInvalid` | `ApiUrl` set, `AuthMode="CertificateBased"` | Throws `AnyCAValidationException` with message matching `"*AuthMode*must be one of*"` | -| `ValidateCAConnectionInfo_SkipsValidation_WhenDisabled` | `Enabled=false`, nothing else set | Does not throw; no calls made to the mock client | - -### ValidateProductInfo - -| Test | Input | Assertion | -|------|-------|-----------| -| `ValidateProductInfo_Throws_WhenProfileIdMissing` | `ProductID = string.Empty`, valid connection info | Throws `AnyCAValidationException` with message matching `"*ProfileId*required*"` | +`GetProductIds()` returns a hardcoded static list — no API call is made. The strict mock's +`VerifyNoOtherCalls()` confirms this. ### Enroll -The `Enroll` method accepts an `EnrollmentType` parameter. `New` and `Reissue` both route to `EnrollCertificateAsync`. `RenewOrReissue` routes to `RenewCertificateAsync` when `PriorCertSN` is present in `ProductParameters`, and falls back to `EnrollCertificateAsync` when it is not. +The `Enroll` method selects a path based on `EnrollmentType`. Both `New` and `Reissue` submit a +new `GenerateOrderSSL` order. `RenewOrReissue` also submits `GenerateOrderSSL` (CERTInext has +no dedicated renewal endpoint) but applies the renewal-window check to determine how Command +tracks the old→new certificate relationship. | Test | EnrollmentType | Mock setup | Assertion | |------|---------------|-----------|-----------| -| `Enroll_New_CallsEnrollAsync_AndReturnsIssuedResult` | `New` | `EnrollCertificateAsync` (matching `ProfileId == ProfileIdTls`) returns `IssuedEnrollResponse()` | `CARequestID == CertId1`; `Status == EndEntityStatus.GENERATED`; `Certificate` contains `"BEGIN CERTIFICATE"`; client called once | -| `Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval` | `New` | `EnrollCertificateAsync` returns `PendingEnrollResponse()` | `Status == EndEntityStatus.EXTERNALVALIDATION` | -| `Enroll_New_Throws_WhenProfileIdNotSet` | `New` | No setup (strict mock — any unexpected call throws) | Throws `Exception` with message matching `"*ProfileId*required*"` before calling the client | -| `Enroll_Reissue_AlsoCallsEnrollAsync` | `Reissue` | `EnrollCertificateAsync` returns `IssuedEnrollResponse()` | `Status == EndEntityStatus.GENERATED`; `EnrollCertificateAsync` called once | -| `Enroll_Renew_FallsBackToNewEnroll_WhenNoPriorCertSn` | `RenewOrReissue` | `EnrollCertificateAsync` returns `IssuedEnrollResponse()` | `CARequestID == CertId1`; `EnrollCertificateAsync` called once; `RenewCertificateAsync` never called | +| `Enroll_New_CallsEnrollAsync_AndReturnsIssuedResult` | `New` | `PlaceOrderAsync` returns `ORD-AAA-111` | `CARequestID == "ORD-AAA-111"`; `Status == GENERATED` | +| `Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval` | `New` | `PlaceOrderAsync` → pending status | `Status == EXTERNALVALIDATION` | +| `Enroll_New_Throws_WhenProfileIdNotSet` | `New` | Strict mock — no setups | Throws before calling the client | +| `Enroll_Reissue_AlsoCallsEnrollAsync` | `Reissue` | `PlaceOrderAsync` returns issued | `Status == GENERATED`; called once | +| `Enroll_Renew_FallsBackToNewEnroll_WhenNoPriorCertSn` | `RenewOrReissue` | `PlaceOrderAsync` returns issued | `CARequestID == "ORD-AAA-111"`; no dedicated renew call | ### GetSingleRecord | Test | Mock setup | Assertion | |------|-----------|-----------| -| `GetSingleRecord_ReturnsMappedCertificate_ForIssuedCert` | `GetCertificateAsync(CertId1)` returns `IssuedCertRecord()` | `CARequestID == CertId1`; `Status == EndEntityStatus.GENERATED`; `Certificate` contains `"BEGIN CERTIFICATE"`; `ProductID == ProfileIdTls` | -| `GetSingleRecord_ReturnsMappedCertificate_ForRevokedCert` | `GetCertificateAsync(CertId3)` returns `RevokedCertRecord()` | `Status == EndEntityStatus.REVOKED`; `RevocationDate` is not null; `RevocationReason == 1` (keyCompromise) | -| `GetSingleRecord_Rethrows_WhenCertNotFound` | `GetCertificateAsync("no-such-id")` throws `KeyNotFoundException` | Rethrows `KeyNotFoundException` | +| `GetSingleRecord_ReturnsMappedCertificate_ForIssuedCert` | `TrackOrderAsync("ORD-AAA-111")` returns issued track response; `GetCertificateAsync` returns PEM | `Status == GENERATED`; PEM present; `ProductID == ProfileIdTls` | +| `GetSingleRecord_ReturnsMappedCertificate_ForRevokedCert` | `TrackOrderAsync("ORD-CCC-333")` returns revoked response | `Status == REVOKED`; `RevocationDate` non-null; `RevocationReason == 1` | +| `GetSingleRecord_Rethrows_WhenCertNotFound` | Client throws `KeyNotFoundException` | Rethrows `KeyNotFoundException` | ### Revoke -The plugin looks up the certificate first to check whether it is already revoked, then calls `RevokeCertificateAsync` only if it is not. CRL reason codes (integers) are mapped to string values expected by the CERTInext API. +The plugin checks the current certificate status before calling `RevokeOrder`. CRL reason codes +(integers) are mapped to CERTInext string values. | Test | Mock setup | Assertion | |------|-----------|-----------| -| `Revoke_CallsRevokeCertificateAsync_AndReturnsRevokedStatus` | `GetCertificateAsync(CertId1)` returns issued cert; `RevokeCertificateAsync(CertId1, ...)` returns `Task.CompletedTask` | Returns `EndEntityStatus.REVOKED`; `RevokeCertificateAsync` called once with `Reason == "keyCompromise"` (CRL code 1) | -| `Revoke_ReturnsAlreadyRevoked_WhenCertAlreadyRevoked` | `GetCertificateAsync(CertId3)` returns revoked cert | Returns `EndEntityStatus.REVOKED`; `RevokeCertificateAsync` never called | -| `Revoke_MapsAllCrlReasonCodes` | For each reason code 0–5: `GetCertificateAsync` returns issued cert; `RevokeCertificateAsync` matched only when `Reason` equals the expected string | Verifies the complete mapping: `0→"unspecified"`, `1→"keyCompromise"`, `2→"caCompromise"`, `3→"affiliationChanged"`, `4→"superseded"`, `5→"cessationOfOperation"` | +| `Revoke_CallsRevokeCertificateAsync_AndReturnsRevokedStatus` | `TrackOrderAsync` returns issued cert; `RevokeOrderAsync` returns `Task.CompletedTask` | Returns `REVOKED`; `RevokeOrderAsync` called once with correct reason string | +| `Revoke_ReturnsAlreadyRevoked_WhenCertAlreadyRevoked` | `TrackOrderAsync` returns revoked cert | Returns `REVOKED`; `RevokeOrderAsync` never called | +| `Revoke_MapsAllCrlReasonCodes` | Per reason code 0–5 and beyond | Verifies mapping: `0→"unspecified"`, `1→"keyCompromise"`, `2→"caCompromise"`, `3→"affiliationChanged"`, `4→"superseded"`, `5→"cessationOfOperation"`, extended codes also covered by `CERTInextCAPluginCoverageTests` | ### Synchronize -`Synchronize` iterates `ListCertificatesAsync` and adds mapped `AnyCAPluginCertificate` objects to a `BlockingCollection`. A full sync passes `null` as `issuedAfter`; a delta sync passes the `lastSync` timestamp. Certificates with a status that cannot be mapped (e.g., `"failed"`) are skipped. +`Synchronize` iterates `ListOrdersAsync` and posts mapped `AnyCAPluginCertificate` objects to a +`BlockingCollection`. Full sync passes `null` as `issuedAfter`; delta sync passes `lastSync`. | Test | Mock setup | Assertion | |------|-----------|-----------| -| `Synchronize_FullSync_AddsAllCertsToBuffer` | `ListCertificatesAsync(null, ...)` returns two issued certs (`CertId1`, `CertId2`) | Buffer contains 2 items; both IDs present | -| `Synchronize_DeltaSync_PassesLastSyncFilter` | `ListCertificatesAsync` captures the `issuedAfter` argument and returns one cert | Captured `issuedAfter` equals the `lastSync` value passed to `Synchronize` | -| `Synchronize_FullSync_PassesNullIssuedAfter` | `ListCertificatesAsync` captures `issuedAfter` and returns empty | Even when `lastSync` is non-null, `fullSync: true` causes `issuedAfter` to be passed as `null` | -| `Synchronize_SkipsFailedCertificates` | `ListCertificatesAsync` returns one issued cert and one cert with `status="failed"` and `Certificate=null` | Buffer contains exactly 1 item (`CertId1`); the failed cert is dropped | -| `Synchronize_HonoursCancellation` | Custom async enumerable that yields one cert, cancels the `CancellationTokenSource`, then calls `ct.ThrowIfCancellationRequested()` before yielding a second | Throws `OperationCanceledException` | -| `Synchronize_MapsRevokedCertificates_Correctly` | `ListCertificatesAsync` returns one revoked cert (`CertId3`) | Buffer contains 1 item; `Status == EndEntityStatus.REVOKED`; `RevocationDate` is not null | -| `Synchronize_CallsCompleteAdding_OnNormalExit` | `ListCertificatesAsync` returns empty | `buffer.IsAddingCompleted == true` after `Synchronize` returns normally | -| `Synchronize_CallsCompleteAdding_OnCancellation` | Custom async enumerable that cancels mid-iteration | `buffer.IsAddingCompleted == true` even after `OperationCanceledException` is thrown | +| `Synchronize_FullSync_AddsAllCertsToBuffer` | `ListOrdersAsync(null, ...)` returns two issued orders | Buffer contains 2 items; both order numbers present | +| `Synchronize_DeltaSync_PassesLastSyncFilter` | `ListOrdersAsync` captures `issuedAfter` | Captured value equals `lastSync` | +| `Synchronize_FullSync_PassesNullIssuedAfter` | `ListOrdersAsync` captures `issuedAfter` | Even when `lastSync` is non-null, `fullSync:true` forces `issuedAfter=null` | +| `Synchronize_SkipsFailedCertificates` | Returns one issued + one with unknown/failed status | Buffer contains exactly 1 item | +| `Synchronize_HonoursCancellation` | Async enumerable that cancels mid-iteration | Throws `OperationCanceledException` | +| `Synchronize_MapsRevokedCertificates_Correctly` | Returns one revoked record | Buffer item `Status == REVOKED`; `RevocationDate` non-null | +| `Synchronize_CallsCompleteAdding_OnNormalExit` | Returns empty | `buffer.IsAddingCompleted == true` | +| `Synchronize_CallsCompleteAdding_OnCancellation` | Cancels mid-iteration | `buffer.IsAddingCompleted == true` even after `OperationCanceledException` | + +**Note on `CompleteAdding`:** `Synchronize` calls `blockingBuffer.CompleteAdding()` in a `finally` +block. Tests must not call `buffer.CompleteAdding()` themselves — doing so after the plugin has +already called it throws `InvalidOperationException`. + +--- -**Note on `CompleteAdding`:** `Synchronize` calls `blockingBuffer.CompleteAdding()` in a `finally` block. Tests must NOT call `buffer.CompleteAdding()` themselves — doing so after the plugin has already called it throws `InvalidOperationException`. +## CERTInextCAPluginPublicSurfaceTests -### RenewalWindowDays — P2-C semantic +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. -`RenewalWindowDays` controls whether a `RenewOrReissue` enrollment uses the CERTInext renew API or falls back to a fresh order. The semantics are "Option A — window before expiry": +| Test | What it checks | +|------|---------------| +| `NoPublicConstructor_ReferencesV3Point3OnlyTypes` | No public constructor has a parameter typed as a 3.3-only interface | +| `NoInstanceField_DeclaredTypeReferencesV3Point3OnlyTypes` | No public or private instance field is typed as a 3.3-only type | +| `NoNestedType_ImplementsV3Point3OnlyInterface` | No nested type implements a 3.3-only interface | +| `NoPublicMethod_SignatureReferencesV3Point3OnlyTypes` | No public method has a parameter or return type referencing 3.3-only types | +| `ParameterlessConstructor_IsPublic` | The plugin has a public parameterless constructor (required by the gateway host for reflection-based instantiation) | +| `SetDomainValidatorFactory_AcceptsObject_NotIDomainValidatorFactory` | The DCV injection method accepts `object`, not the 3.3-only `IDomainValidatorFactory`, so the method signature loads on 3.2 hosts | +| `SetDomainValidatorFactory_NullArgument_LeavesDcvDisabled` | Passing `null` does not enable DCV | +| `SetDomainValidatorFactory_NonFactoryArgument_IsIgnored` | Passing a non-factory object does not enable DCV | -``` -useRenewalApi = expiry > DateTime.UtcNow && expiry <= DateTime.UtcNow.AddDays(RenewalWindowDays) -``` +--- + +## BoundedDcvSyncTests -| Test | Expiry | Window | Expected path | -|------|--------|--------|---------------| -| `RenewOrReissue_UsesRenewApi_WhenCertExpiresWithinWindow` | now + 30 days | 90 days | Renewal API | -| `RenewOrReissue_UsesNewEnroll_WhenCertExpiresOutsideWindow` | now + 120 days | 90 days | New enroll (too early) | -| `RenewOrReissue_UsesNewEnroll_WhenCertAlreadyExpired` | now − 5 days | 90 days | New enroll (graceful degradation) | +Pure unit tests for the age-window and per-pass cap logic in `TryRunDcvDuringSyncAsync`. No +network I/O. Verifies that: +- Orders within the configured age window are attempted +- Orders older than the window are skipped (to avoid retrying abandoned orders indefinitely) +- Orders at the exact age boundary are attempted +- Orders with unknown dates are attempted (not starved) +- Age window of 0 disables the filter +- The per-pass cap skips orders once the cap is reached +- Cap of 0 disables the cap +- Age skip takes precedence over the cap check + +--- + +## RateLimitRetryTests + +Pure unit tests for the `IsRateLimitSurface` and `ComputeRateLimitBackoffSeconds` helpers: +- `IsRateLimitSurface` recognises the documented CERTInext rate-limit error phrase and rejects + unrelated strings +- `ComputeRateLimitBackoffSeconds` produces a result within the expected jittered range for each + attempt number +- Attempt values below 1 are clamped to 1 --- ## MockCertificateData -`MockCertificateData` is a static internal class shared by both test suites. It provides two types of output: +`MockCertificateData` is a static internal class shared across test suites. It provides realistic +fake CERTInext API response objects and JSON payloads. -- **Object helpers** — return typed API response objects for use in Moq setups -- **JSON helpers** — return raw JSON strings for use in WireMock stubs +The real CERTInext API uses HTTP POST for all endpoints and wraps every response in a `meta` +block with `status: "1"` (success) or `status: "0"` (failure). ### Constants | Constant | Value | Used for | |----------|-------|---------| | `FakePemCertificate` | PEM block starting with `-----BEGIN CERTIFICATE-----` | Certificate body in all responses | -| `FakeCsrPem` | PEM block starting with `-----BEGIN CERTIFICATE REQUEST-----` | CSR body in enroll and renew requests | -| `CertId1` | `"cert-aaa-111"` | Default issued certificate ID | -| `CertId2` | `"cert-bbb-222"` | Second certificate ID (pagination, delta sync) | -| `CertId3` | `"cert-ccc-333"` | Default revoked certificate ID | -| `ProfileIdTls` | `"tls-server"` | TLS server profile | -| `ProfileIdClient` | `"client-auth"` | Client authentication profile | - -### Object helpers (Moq) +| `FakeCsrPem` | PEM block starting with `-----BEGIN CERTIFICATE REQUEST-----` | CSR body in enroll requests | +| `OrderNumber1` | `"ORD-AAA-111"` | Primary order number (also aliased as `CertId1`) | +| `OrderNumber2` | `"ORD-BBB-222"` | Second order number (also aliased as `CertId2`) | +| `OrderNumber3` | `"ORD-CCC-333"` | Revoked order number (also aliased as `CertId3`) | +| `ProfileIdTls` | `"tls-server"` | TLS server product code placeholder | +| `ProfileIdClient` | `"client-auth"` | Client auth product code placeholder | + +`CertId1/2/3` are backward-compatibility aliases for `OrderNumber1/2/3`. + +### JSON helpers (WireMock stubs) + +| Method | Endpoint | Notes | +|--------|----------|-------| +| `ValidateCredentialsSuccessJson()` | `POST /ValidateCredentials` | Success meta only | +| `ValidateCredentialsFailureJson(code, msg)` | `POST /ValidateCredentials` | Failure meta | +| `GenerateOrderSuccessJson(orderNumber)` | `POST /GenerateOrderSSL` | Includes `orderDetails.orderNumber` | +| `TrackOrderIssuedJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="9"` (GENERATED) | +| `TrackOrderPendingJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="1"` (SetupPending) | +| `TrackOrderRevokedJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="22"`, revocation details present | +| `GetCertificateSuccessJson()` | `POST /GetCertificate` | PEM in `certificateDetails.endEntityCertificate`; serial `"0A1B2C3D4E5F"` | +| `RevokeSuccessJson()` | `POST /RevokeOrder` | Success meta only | +| `OrderReportSinglePageJson()` | `POST /GetOrderReport` | One entry, `ORD-AAA-111` | +| `OrderReportPageJson(orderNumbers, total, pages, current)` | `POST /GetOrderReport` | Multi-entry paginated response | +| `OrderReportEmptyJson()` | `POST /GetOrderReport` | Empty `ordersArray`, `noOfPages=0` | +| `GetProductDetailsJson()` | `POST /GetProductDetails` | Nested category envelope with two products | +| `GetProductDetailsEmptyJson()` | `POST /GetProductDetails` | Empty `productDetails` array | +| `ApiFailureJson(code, msg)` | Any endpoint | Generic `meta.status="0"` failure | +| `GetDcvSuccessJson(token)` | `POST /GetDcv` | `dcvDetails.token` | +| `GetDcvFailureJson(code, msg)` | `POST /GetDcv` | Failure meta | +| `VerifyDcvSuccessJson()` | `POST /VerifyDcv` | Success meta only | +| `VerifyDcvFailureJson(code, msg)` | `POST /VerifyDcv` | Failure meta | +| `OAuth2TokenJson(expiresIn)` | OAuth token endpoint | `access_token="fake-bearer-token-abc123"` | +| `ServerErrorJson()` | Any | Generic 500 error body (not meta-wrapped) | +| `UnauthorizedJson()` | Any | Generic 401 error body (not meta-wrapped) | + +### Object helpers (Moq setups) | Method | Returns | |--------|---------| | `ActiveProfiles()` | Two `ProfileInfo` objects, both `Active=true`: `ProfileIdTls` and `ProfileIdClient` | | `MixedProfiles()` | Three `ProfileInfo` objects: `ProfileIdTls` (active), `"legacy-profile"` (inactive), `ProfileIdClient` (active) | -| `IssuedEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="issued"`, `FakePemCertificate`, `SerialNumber="0A1B2C3D4E5F"` | +| `IssuedEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="issued"`, PEM, `SerialNumber="0A1B2C3D4E5F"` | | `PendingEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="pending_approval"`, `Certificate=null` | -| `IssuedCertRecord(id)` | `GetCertificateResponse` with `Status="issued"`, `FakePemCertificate`, `ProfileId=ProfileIdTls`, issued 2024-06-01, expires 2025-06-01 | -| `RevokedCertRecord(id)` | `GetCertificateResponse` with `Status="revoked"`, `RevokedAt=2024-03-15`, `RevocationReason="keyCompromise"` | - -### JSON helpers (WireMock) - -| Method | Returns | -|--------|---------| -| `EnrollResponseJson(id, status)` | Enroll response JSON with `status="issued"` and `FakePemCertificate` escaped for JSON | -| `PendingEnrollResponseJson(id)` | Enroll response JSON with `status="pending_approval"` and `certificate:null` | -| `GetCertificateJson(id, status)` | Single certificate JSON including SANs, subject, CSR, and revocation fields | -| `RevokedCertificateJson(id)` | Certificate JSON with `status="revoked"` and revocation fields populated | -| `SinglePageListJson(id)` | Paginated list JSON: one cert on page 1 of 1 | -| `TwoPageListJson(page)` | Paginated list JSON: call with `page=1` or `page=2` to get the respective page of a two-page result set | -| `RevokeSuccessJson()` | `{"success":true,"message":"Certificate revoked successfully."}` | -| `RenewResponseJson(newId)` | Renew response JSON with a new certificate ID | -| `HealthOkJson()` | `{"status":"ok","version":"2.1.0"}` | -| `OAuth2TokenJson(expiresIn)` | OAuth2 token response with `access_token="fake-bearer-token-abc123"` | -| `ProfilesJson(profiles)` | Profiles list JSON; defaults to `ActiveProfiles()` if no argument passed | -| `NotFoundErrorJson(id)` | 404 error body with the given ID in the message | -| `ServerErrorJson()` | Generic 500 error body | -| `UnauthorizedJson()` | 401 error body | - -`EscapeForJson` is a private helper used internally to embed `FakePemCertificate` and `FakeCsrPem` (which contain newlines and no special JSON escaping) inside JSON string values. +| `IssuedCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="issued"`, PEM, `ProfileId=ProfileIdTls` | +| `PendingCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="pending_approval"`, no certificate — maps to `EXTERNALVALIDATION` | +| `RevokedCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="revoked"`, `RevokedAt`, `RevocationReason="keyCompromise"` | +| `DcvPendingTrackResponse(orderNumber, domain)` | `TrackOrderResponse` with one DNS-TXT entry at `dcvStatus="0"` (pending) | +| `DcvVerifiedTrackResponse(orderNumber, domain)` | `TrackOrderResponse` with DNS-TXT entry at `dcvStatus="1"` (validated) | +| `AlreadyIssuedTrackResponse(orderNumber)` | `TrackOrderResponse` with `certificateStatusId="9"` (GENERATED) — DCV should be skipped | +| `DcvTokenResponse(token)` | `GetDcvResponse` with `DcvDetails.Token` set | --- @@ -295,20 +390,23 @@ useRenewalApi = expiry > DateTime.UtcNow && expiry <= DateTime.UtcNow.AddDays(Re ### Which suite to add to -- **Add to `CERTInextClientTests`** when testing HTTP-level behavior: a new endpoint, a new error status code, authentication header details, query parameter serialization, or any behavior where the actual request sent over the wire matters. -- **Add to `CERTInextCAPluginTests`** when testing plugin logic: a new enrollment type, a new validation rule, a new status mapping, or how the plugin responds to specific client return values or exceptions. +- **`CERTInextClientTests`** — when testing HTTP-level behaviour: a new endpoint, error status + code, authentication header detail, body serialisation, or query parameter. +- **`CERTInextClientRequestShapeTests`** — when verifying that the request body includes or omits + specific JSON blocks based on connector configuration. +- **`CERTInextCAPluginTests` / `CERTInextCAPluginCoverageTests`** — when testing plugin logic: a + new enrollment type, validation rule, status mapping, or response to specific client return values. ### Adding a new WireMock stub -1. Register a stub in the test body using the existing pattern: +1. Register a stub in the test body: ```csharp _server - .Given(Request.Create().WithPath("/api/v1/your-endpoint").UsingGet()) + .Given(Request.Create().WithPath("/YourEndpoint").UsingPost()) .RespondWith(Response.Create() .WithStatusCode(200) .WithHeader("Content-Type", "application/json") - .WithBody(@"{""yourField"":""yourValue""}")); + .WithBody(MockCertificateData.YourResponseJson())); ``` -2. If the response shape is reused across tests, add a JSON helper to `MockCertificateData` following the same `string YourResponseJson(...)` convention. -3. If you need a typed object for a Moq setup that mirrors the new JSON, add a corresponding object helper (e.g., `YourResponse()`) that returns a populated API response object. -4. Verify request details (headers, query parameters, body) by inspecting `_server.LogEntries` after the call, following the pattern in `PingAsync_SendsApiKeyHeader_WhenAuthModeIsApiKey` and `ListCertificatesAsync_RespectsIssuedAfterFilter`. +2. Add a `YourResponseJson(...)` JSON helper to `MockCertificateData` if the shape is reused. +3. Verify request details by inspecting `_server.LogEntries` after the call. diff --git a/CERTInext/API/CertificateRequest.cs b/CERTInext/API/CertificateRequest.cs index 2db42c6..7f02df0 100644 --- a/CERTInext/API/CertificateRequest.cs +++ b/CERTInext/API/CertificateRequest.cs @@ -142,6 +142,14 @@ public class SslOrderDetails [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public OrganizationDetails OrganizationDetails { get; set; } + [JsonPropertyName("delegationInformation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DelegationInformation DelegationInformation { get; set; } + + [JsonPropertyName("technicalPointOfContact")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TechnicalPointOfContact TechnicalPointOfContact { get; set; } + [JsonPropertyName("additionalInformation")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public AdditionalInformation AdditionalInformation { get; set; } @@ -224,6 +232,39 @@ public class OrganizationDetails public string OrganizationNumber { get; set; } } + /// + /// Routes the order to a specific account group within CERTInext. Required by many + /// accounts even though the V1 docs list it as optional — without it, orders may be + /// placed against the default group and queued for additional review. + /// + public class DelegationInformation + { + [JsonPropertyName("groupNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string GroupNumber { get; set; } + } + + /// + /// Technical point of contact metadata sent with SSL orders. CERTInext uses these + /// fields as the secondary contact for issuance-related notifications. When omitted, + /// some product configurations queue the order in Pending System RA waiting + /// for the field to be populated manually. + /// + public class TechnicalPointOfContact + { + [JsonPropertyName("tpcName")] + public string TpcName { get; set; } + + [JsonPropertyName("tpcEmail")] + public string TpcEmail { get; set; } + + [JsonPropertyName("tpcIsdCode")] + public string TpcIsdCode { get; set; } = "1"; + + [JsonPropertyName("tpcMobileNumber")] + public string TpcMobileNumber { get; set; } + } + public class AdditionalInformation { [JsonPropertyName("remarks")] @@ -288,6 +329,87 @@ public class TrackOrderDetails public string OrderNumber { get; set; } } + // --------------------------------------------------------------------------- + // GetDcv — POST {baseURL}GetDcv + // Retrieves Domain Control Validation token / file content / approver emails + // for a given (orderNumber, domainName, dcvMethod) tuple. + // + // The CERTInext V1 spec defines this body as wrapped in a "dcvDetails" block. + // Note: the Postman example for GetDcv uses "orderDetails" instead — this is + // an example typo; the inline spec, the response body, and the VerifyDcv body + // all use "dcvDetails" consistently. + // --------------------------------------------------------------------------- + + /// + /// Request body for POST {baseURL}GetDcv. + /// Returns DCV instructions (token / file / approver emails) for one domain + /// in the given order. + /// + public class GetDcvRequest + { + [JsonPropertyName("meta")] + public RequestMeta Meta { get; set; } + + [JsonPropertyName("dcvDetails")] + public DcvRequestDetails DcvDetails { get; set; } + } + + /// + /// Common request body for both GetDcv and VerifyDcv — both endpoints take the + /// same set of identification fields. is only set on + /// VerifyDcv requests when = email (3). + /// + public class DcvRequestDetails + { + /// Registered requestor email associated with the order. + [JsonPropertyName("requestorEmail")] + public string RequestorEmail { get; set; } + + /// Order number returned by GenerateOrderSSL. + [JsonPropertyName("orderNumber")] + public string OrderNumber { get; set; } + + /// Domain to retrieve / verify DCV for. + [JsonPropertyName("domainName")] + public string DomainName { get; set; } + + /// + /// DCV method (numeric string per CERTInext V1 spec): + /// "1" = DNS TXT record, "2" = HTTP file, "3" = email approver. + /// See . + /// + [JsonPropertyName("dcvMethod")] + public string DcvMethod { get; set; } + + /// + /// Approver email address. Required (and only used) on VerifyDcv when + /// is "3" (email). Must be one of the + /// dcvEmails returned by GetDcv. + /// + [JsonPropertyName("dcvEmail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string DcvEmail { get; set; } + } + + // --------------------------------------------------------------------------- + // VerifyDcv — POST {baseURL}VerifyDcv + // Triggers CERTInext to verify the DCV record placed by the customer. + // --------------------------------------------------------------------------- + + /// + /// Request body for POST {baseURL}VerifyDcv. + /// Tells CERTInext to attempt domain verification using the previously + /// supplied DCV details. Reuses . + /// + public class VerifyDcvRequest + { + [JsonPropertyName("meta")] + public RequestMeta Meta { get; set; } + + [JsonPropertyName("dcvDetails")] + public DcvRequestDetails DcvDetails { get; set; } + } + // --------------------------------------------------------------------------- // GetCertificate — POST {baseURL}GetCertificate // Downloads the issued certificate for a fulfilled order. diff --git a/CERTInext/API/CertificateResponse.cs b/CERTInext/API/CertificateResponse.cs index 0f3ca67..3b3103f 100644 --- a/CERTInext/API/CertificateResponse.cs +++ b/CERTInext/API/CertificateResponse.cs @@ -6,6 +6,7 @@ // and limitations under the License. using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; namespace Keyfactor.Extensions.CAPlugin.CERTInext.API @@ -151,10 +152,100 @@ public class TrackOrderResponseDetails [JsonPropertyName("revocationDetails")] public TrackOrderRevocationDetails RevocationDetails { get; set; } + /// + /// Per-domain DCV state plus a top-level status field. The wire + /// shape mixes typed and dynamic keys: { "<Domain Name>": { ... }, + /// "status": "..." }, so domain entries are surfaced via + /// . + /// + [JsonPropertyName("domainVerification")] + public TrackOrderDomainVerification DomainVerification { get; set; } + [JsonPropertyName("csr")] public string Csr { get; set; } } + /// + /// domainVerification block from TrackOrder. Wire shape is heterogeneous: + /// a known status field at the top level alongside one entry per domain + /// keyed by domain name. The per-domain entries are captured via + /// and exposed through + /// . + /// + public class TrackOrderDomainVerification + { + /// Block-level status. Documented values mirror . + [JsonPropertyName("status")] + public string Status { get; set; } + + /// + /// Raw per-domain entries as parsed from the response. Keys are the domain + /// names exactly as returned by CERTInext. Use + /// for typed access. + /// + [JsonExtensionData] + public Dictionary RawDomainEntries { get; set; } + + /// + /// Returns a typed dictionary of domain → , + /// skipping entries that fail to deserialize (e.g. unexpected scalar values). + /// Returns an empty dictionary if no per-domain entries were present. + /// + public Dictionary GetDomainEntries() + { + var result = new Dictionary(); + if (RawDomainEntries == null) return result; + + foreach (var kv in RawDomainEntries) + { + if (kv.Value.ValueKind != JsonValueKind.Object) continue; + try + { + var detail = kv.Value.Deserialize(); + if (detail != null) result[kv.Key] = detail; + } + catch (JsonException) + { + // ignore: entry shape unexpected, skip rather than failing the whole TrackOrder + } + } + + return result; + } + } + + /// + /// Per-domain DCV detail inside . + /// + public class DomainVerificationDetail + { + /// DCV method used / requested for this domain (typically the human label). + [JsonPropertyName("dcvMethod")] + public string DcvMethod { get; set; } + + /// + /// DCV completion status: "0"=Pending, "1"=Validated, "2"=Rejected. + /// See . + /// + [JsonPropertyName("dcvStatus")] + public string DcvStatus { get; set; } + + /// Domain status: "1"=Active, "2"=Inactive, "3"=Expired. + [JsonPropertyName("status")] + public string Status { get; set; } + + /// Timestamp at which the domain was successfully verified (when applicable). + [JsonPropertyName("verifiedDate")] + public string VerifiedDate { get; set; } + + /// + /// CAA check status: "1"=emSign authorized or no CAA present, + /// "2"=Authorization required, "3"=Authorization pending. + /// + [JsonPropertyName("caaStatus")] + public string CaaStatus { get; set; } + } + public class TrackOrderRequestorInfo { [JsonPropertyName("requestorName")] @@ -189,6 +280,84 @@ public class TrackOrderRevocationDetails public string RevokeRequestStatus { get; set; } } + // --------------------------------------------------------------------------- + // GetDcv response — POST {baseURL}GetDcv + // + // Per the V1 spec the dcvDetails block contains different fields depending + // on the dcvMethod that was requested: + // dcvMethod=1 (DNS TXT) → token populated + // dcvMethod=2 (HTTP) → fileName + fileContent populated + // dcvMethod=3 (email) → dcvEmails populated + // + // The TXT record HOSTNAME for dcvMethod=1 is NOT returned by this endpoint. + // The CERTInext V1 documentation does not specify the convention. The plugin + // uses Constants.Dcv.DefaultTxtRecordTemplate ("_emsign-validation.{0}") by + // default, overridable via the DcvTxtRecordTemplate connector config field. + // --------------------------------------------------------------------------- + + /// + /// Response from POST {baseURL}GetDcv. + /// + public class GetDcvResponse + { + [JsonPropertyName("meta")] + public ResponseMeta Meta { get; set; } + + [JsonPropertyName("dcvDetails")] + public DcvResponseDetails DcvDetails { get; set; } + } + + /// + /// DCV instructions returned by GetDcv. Field population depends on the + /// requested dcvMethod (see class-level remarks on ). + /// + public class DcvResponseDetails + { + /// + /// Token / target address value to publish for DNS TXT-based DCV + /// (dcvMethod = 1). Empty for other methods. + /// + [JsonPropertyName("token")] + public string Token { get; set; } + + /// + /// File name to host under /.well-known/pki-validation/ for HTTP + /// DCV (dcvMethod = 2). Empty for other methods. + /// + [JsonPropertyName("fileName")] + public string FileName { get; set; } + + /// + /// File body to serve at the well-known path for HTTP DCV (dcvMethod = 2). + /// Empty for other methods. + /// + [JsonPropertyName("fileContent")] + public string FileContent { get; set; } + + /// + /// CA/B Forum approved approver email candidates for email DCV + /// (dcvMethod = 3). Empty for other methods. + /// + [JsonPropertyName("dcvEmails")] + public List DcvEmails { get; set; } + } + + // --------------------------------------------------------------------------- + // VerifyDcv response — POST {baseURL}VerifyDcv + // Body contains only the meta block (success/failure status). + // --------------------------------------------------------------------------- + + /// + /// Response from POST {baseURL}VerifyDcv. Body is meta-only; the actual + /// per-domain verification status is observed via subsequent TrackOrder + /// calls (see ). + /// + public class VerifyDcvResponse + { + [JsonPropertyName("meta")] + public ResponseMeta Meta { get; set; } + } + // --------------------------------------------------------------------------- // GetCertificate response — POST {baseURL}GetCertificate // --------------------------------------------------------------------------- @@ -602,6 +771,14 @@ public class LegacyGetCertificateResponse [JsonPropertyName("expiresAt")] public System.DateTime? ExpiresAt { get; set; } + /// + /// Order placement date parsed from orderDate in the order report. Distinct from + /// (a pending order has no issuance date) — used to bound + /// DCV-during-sync to recently-placed orders (issue 0002). + /// + [JsonPropertyName("orderDate")] + public System.DateTime? OrderDate { get; set; } + /// Revocation date parsed from revokeProcessedDate in TrackOrder revocationDetails. [JsonPropertyName("revokedAt")] public System.DateTime? RevokedAt { get; set; } diff --git a/CERTInext/CERTInext.csproj b/CERTInext/CERTInext.csproj index b25bd55..f683ea8 100644 --- a/CERTInext/CERTInext.csproj +++ b/CERTInext/CERTInext.csproj @@ -1,25 +1,44 @@ - net8.0 + net8.0;net10.0 Keyfactor.Extensions.CAPlugin.CERTInext CERTInextCAPlugin disable warnings 12.0 + + false + $(DefineConstants);SUPPORTS_DCV true - + + + - + + - @@ -32,5 +51,8 @@ <_Parameter1>CERTInext.Tests + + <_Parameter1>CERTInext.IntegrationTests + diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 1ca2770..231f611 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -19,6 +19,9 @@ using Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; using Microsoft.Extensions.Logging; +#if SUPPORTS_DCV +using IDomainValidatorFactory = Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory; +#endif namespace Keyfactor.Extensions.CAPlugin.CERTInext { @@ -34,25 +37,79 @@ public class CERTInextCAPlugin : IAnyCAPlugin, IDisposable private CERTInextConfig _config; private ICERTInextClient _client; private ICertificateDataReader _certificateDataReader; + // Typed as `object` — NOT `IDomainValidatorFactory` — so the .NET JIT does not + // eagerly resolve the v3.3-only IDomainValidatorFactory type when it compiles + // any method on this class. Resolving an instance field's declared type is + // part of the JIT's per-class metadata load, distinct from constructor-signature + // reflection (which we already protected in the issue #7 first pass). On a + // gateway host whose IAnyCAPlugin assembly is v3.2.0.0 (no IDomainValidatorFactory), + // declaring the field with the missing type causes TypeLoadException the first + // time ANY instance method on the class is compiled — typically Initialize. + // + // Reads of this field perform an `as IDomainValidatorFactory` cast inside method + // bodies (see DomainValidatorFactory below). Casts in method bodies are JIT-lazy + // per-method, so the type is only resolved on hosts that actually have it. + // + // `volatile` because the field is written by SetDomainValidatorFactory and read + // by EnrollNewAsync / TryRunDcvDuringSyncAsync, which can run on different threads. + // See GitHub issue #7 for the full reasoning. + // On the no-DCV build (IAnyCAPlugin 3.2.0, SUPPORTS_DCV undefined) this field is + // intentionally never assigned — its assignment sites (the factory ctor and + // SetDomainValidatorFactory) are fenced out, so it stays null and the Initialize + // DCV-wiring check reports "not wired". Suppress CS0649 for that case; on the + // SUPPORTS_DCV build it is assigned normally and the pragma is a no-op. +#pragma warning disable CS0649 + private volatile object _domainValidatorFactory; +#pragma warning restore CS0649 + + /// + /// Returns the injected when one is + /// available, or null when DCV is not wired up. The cast is inside this + /// property body (and therefore JIT-lazy) so the missing-type case on a v3.2 + /// gateway host stays compileable and never triggers TypeLoadException + /// at runtime. All read sites in this class go through this property. + /// +#if SUPPORTS_DCV + private IDomainValidatorFactory DomainValidatorFactory => + _domainValidatorFactory as IDomainValidatorFactory; +#endif // True when the client was passed in via a test-injection constructor and therefore // should not be disposed by this class (the test owns the mock's lifetime). private bool _clientWasInjected; + // Guards against concurrent DCV attempts on the same order — two overlapping sync + // cycles, or a sync overlapping with a GetSingleRecord refresh, must not both try + // to stage TXT records for the same order. The value byte is unused; this is a set. + private readonly ConcurrentDictionary _dcvInFlight = new(); + // --------------------------------------------------------------------------- // Constructors // --------------------------------------------------------------------------- - /// Production constructor — called by the gateway framework via reflection. + /// + /// Production constructor — the only public constructor the gateway DI container + /// sees. Deliberately parameterless to ensure plugin load succeeds on gateway + /// versions whose Keyfactor.AnyGateway.IAnyCAPlugin assembly does not + /// contain (e.g. 25.4.0 ships v3.2.0.0). + /// + /// If the host gateway exposes an instance + /// it should be injected via after + /// construction. When no factory is provided, DCV silently no-ops and orders + /// are returned in their pending state for the gateway to advance on the next + /// sync cycle. + /// + /// See . + /// public CERTInextCAPlugin() { } /// - /// Test-injection constructor — pass a mock - /// to avoid real network calls in unit tests. A default configuration is - /// supplied so that methods that read _config do not null-fault when - /// has not been called. + /// Internal constructor used by unit and integration tests to inject a mock + /// and bypass network I/O. A default + /// is supplied so callers that don't invoke + /// can still read _config. /// - public CERTInextCAPlugin(ICERTInextClient client) + internal CERTInextCAPlugin(ICERTInextClient client) { _client = client; _clientWasInjected = true; @@ -60,11 +117,11 @@ public CERTInextCAPlugin(ICERTInextClient client) } /// - /// Test-injection constructor — pass both a mock + /// Internal test-injection constructor — pass a mock /// and a mock for tests that exercise /// RenewOrReissue logic that reads prior certificate data from Command's database. /// - public CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDataReader) + internal CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDataReader) { _client = client; _clientWasInjected = true; @@ -73,17 +130,73 @@ public CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDat } /// - /// Test-injection constructor — pass both a mock + /// Internal test-injection constructor — pass a mock /// and a specific for tests that need to override /// configuration fields such as IgnoreExpired. /// - public CERTInextCAPlugin(ICERTInextClient client, CERTInextConfig config) + internal CERTInextCAPlugin(ICERTInextClient client, CERTInextConfig config) { _client = client; _clientWasInjected = true; _config = config ?? new CERTInextConfig(); } + /// + /// Internal test-injection constructor — pass a mock client, a domain validator + /// factory, and an optional config for unit-testing the DCV orchestration path. + /// + /// This constructor is internal (rather than public) because the + /// gateway DI container's constructor-discovery reflection on a v3.2 host would + /// trip 's missing-type load if this signature + /// were exposed publicly. Tests in CERTInext.Tests / + /// CERTInext.IntegrationTests can still reach it via + /// [InternalsVisibleTo]. See issue #7. + /// +#if SUPPORTS_DCV + internal CERTInextCAPlugin(ICERTInextClient client, IDomainValidatorFactory domainValidatorFactory, CERTInextConfig config = null) + { + _client = client; + _clientWasInjected = true; + _domainValidatorFactory = domainValidatorFactory; + _config = config ?? new CERTInextConfig(); + } +#endif + + /// + /// Injects an after construction. Intended + /// for gateway hosts that can resolve the factory from their own service container + /// and want DCV enabled — they should call this between new CERTInextCAPlugin() + /// and . + /// + /// Accepts rather than + /// so the public method signature does not pull the v3.3-only type into the type's + /// reflection surface on older gateways. When the supplied value is not an + /// , DCV is left disabled. + /// + public void SetDomainValidatorFactory(object factory) + { +#if SUPPORTS_DCV + var typed = factory as IDomainValidatorFactory; + // SOX change-management / SOC2 CC6.1: log every factory injection so an auditor + // can confirm which DNS provider plugin is being used to publish TXT records. + // A bad-faith host could otherwise swap the factory mid-lifecycle with no trail. + // We deliberately do NOT log the factory instance itself — only its type — to + // avoid serialising any state it may carry. + _logger.LogInformation( + "Domain validator factory set on CERTInext plugin. " + + "OfferedType={OfferedType}, Accepted={Accepted}", + factory?.GetType().FullName ?? "(null)", typed != null); + _domainValidatorFactory = typed; +#else + // DCV is not supported on this build (IAnyCAPlugin 3.2.0 — no IDomainValidatorFactory). + // Accept the call for host compatibility but leave DCV disabled. See issue 0003. + _logger.LogInformation( + "Domain validator factory offered but DCV is not supported on this build " + + "(IAnyCAPlugin 3.2.0). OfferedType={OfferedType}", + factory?.GetType().FullName ?? "(null)"); +#endif + } + // --------------------------------------------------------------------------- // IDisposable // --------------------------------------------------------------------------- @@ -108,7 +221,7 @@ public void Dispose() /// public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); _certificateDataReader = certificateDataReader; @@ -134,13 +247,31 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa "ApiKeyPresent={ApiKeyPresent}, UsernamePresent={UsernamePresent}, " + "PasswordPresent={PasswordPresent}, OAuth2ClientIdPresent={OAuth2ClientIdPresent}, " + "OAuth2ClientSecretPresent={OAuth2ClientSecretPresent}, OAuth2TokenUrlPresent={OAuth2TokenUrlPresent}, " + - "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}", + "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}, " + + "DcvEnabled={DcvEnabled}, DcvTxtRecordTemplate={DcvTxtRecordTemplate}, " + + "DomainValidatorFactoryInjected={FactoryInjected}", _config.ApiUrl, _config.AuthMode, _config.Enabled, hasApiKey, hasUsername, hasPassword, hasClientId, hasClientSecret, hasTokenUrl, - _config.PageSize, _config.IgnoreExpired); - _logger.MethodExit(LogLevel.Trace); + _config.PageSize, _config.IgnoreExpired, + _config.DcvEnabled, _config.DcvTxtRecordTemplate, + _domainValidatorFactory != null); + + // 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 + // skipped at runtime. The operator should know that on every restart. + if (_config.DcvEnabled && _domainValidatorFactory == null) + { + _logger.LogWarning( + "DcvEnabled=true but no IDomainValidatorFactory has been injected — " + + "DCV will be silently skipped for every enrollment. This usually means the " + + "gateway host is on a release that does not provide IDomainValidatorFactory " + + "(see GitHub issue #7). Install a DNS provider plugin and upgrade to a " + + "gateway image that supplies the factory, or set DcvEnabled=false to clear " + + "this warning."); + } + _logger.MethodExit(LogLevel.Debug); } // --------------------------------------------------------------------------- @@ -188,12 +319,12 @@ public List GetProductIds() /// public async Task Ping() { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); if (!_config.Enabled) { _logger.LogWarning("CERTInext connector is disabled — skipping connectivity test."); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); return; } @@ -211,14 +342,14 @@ public async Task Ping() } finally { - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); } } /// public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); // SOX CC6.1 / SOC2 CC6.1: log the access attempt so that every configuration // change event is traceable in the audit trail. @@ -236,7 +367,7 @@ public async Task ValidateCAConnectionInfo(Dictionary connection _logger.LogWarning( "CA connection validation skipped — connector is disabled. ApiUrl={ApiUrl}", attemptedApiUrl); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); return; } @@ -292,13 +423,15 @@ public async Task ValidateCAConnectionInfo(Dictionary connection } // Attempt a live connectivity test using the supplied credentials + CERTInextConfig tempConfig = null; + CERTInextClient tempClient = null; try { // 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); - var tempConfig = JsonSerializer.Deserialize(rawConfig); - var tempClient = new CERTInextClient(tempConfig); + tempConfig = JsonSerializer.Deserialize(rawConfig); + tempClient = new CERTInextClient(tempConfig); await tempClient.PingAsync(); } catch (Exception ex) @@ -318,17 +451,31 @@ public async Task ValidateCAConnectionInfo(Dictionary connection "Successfully parsed configuration, but could not connect to CERTInext. " + "See gateway logs for details."); } + finally + { + // SOC2 CC6.1 best-effort credential scrubbing: blank out the secret fields + // on the transient config so they aren't reachable from the still-rooted + // tempClient instance after this method returns. Not a hard guarantee + // (the .NET runtime may have already copied them elsewhere) but removes + // the most obvious post-validation reference chain. + if (tempConfig != null) + { + tempConfig.ApiKey = string.Empty; + tempConfig.OAuthClientSecret = string.Empty; + tempConfig.Password = string.Empty; + } + } _logger.LogInformation( "CA connection validation succeeded. ApiUrl={ApiUrl}, AuthMode={AuthMode}", attemptedApiUrl, attemptedAuthMode); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); } /// public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); string rawConfig = JsonSerializer.Serialize(connectionInfo); var tempConfig = JsonSerializer.Deserialize(rawConfig); @@ -385,9 +532,19 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Diction $"Unable to validate profile '{profileId}' against CERTInext. " + "See gateway logs for details."); } + finally + { + // SOC2 CC6.1 best-effort credential scrubbing (see ValidateCAConnectionInfo). + if (tempConfig != null) + { + tempConfig.ApiKey = string.Empty; + tempConfig.OAuthClientSecret = string.Empty; + tempConfig.Password = string.Empty; + } + } _logger.LogInformation("Product/profile validation succeeded. ProfileId={ProfileId}", profileId); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); } // --------------------------------------------------------------------------- @@ -403,7 +560,7 @@ public async Task Enroll( RequestFormat requestFormat, EnrollmentType enrollmentType) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); var ep = new EnrollmentParams(productInfo); @@ -461,7 +618,7 @@ public async Task Enroll( enrollmentType, result.CARequestID, result.Status, result.Certificate != null ? ExtractSerialFromPem(result.Certificate) : "(pending)", subject, ep.ProfileId); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); return result; } @@ -472,12 +629,36 @@ public async Task Enroll( /// public async Task GetSingleRecord(string caRequestID) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); _logger.LogInformation("GetSingleRecord started. CARequestID={Id}", caRequestID); try { var cert = await _client.GetCertificateAsync(caRequestID); + + // Mirror the deferred-DCV behavior of Synchronize: if the order is still in + // a pending state, try to advance it through DCV before returning. This lets + // a manual single-record refresh unstick an order whose DCV challenge was + // only exposed after enrollment returned. + int status = StatusMapper.ToRequestDisposition(cert.Status); + if (status == (int)EndEntityStatus.EXTERNALVALIDATION) + { + bool dcvDone = await TryRunDcvDuringSyncAsync(caRequestID, CancellationToken.None); + if (dcvDone) + { + try + { + cert = await _client.GetCertificateAsync(caRequestID); + } + catch (Exception refetchEx) + { + _logger.LogWarning(refetchEx, + "Single-record DCV completed but post-DCV refetch failed. CARequestID={Id}", + caRequestID); + } + } + } + var record = MapToAnyCAPluginCertificate(cert); // SOC2 CC7.3: certificate retrieval is a security-relevant read operation; @@ -485,7 +666,7 @@ public async Task GetSingleRecord(string caRequestID) _logger.LogInformation( "GetSingleRecord complete. CARequestID={Id}, Status={Status}, SerialNumber={Serial}", caRequestID, cert.Status, cert.SerialNumber ?? "(none)"); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); return record; } catch (KeyNotFoundException) @@ -507,17 +688,22 @@ public async Task GetSingleRecord(string caRequestID) /// public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); string reasonString = StatusMapper.ToRevocationReason(revocationReason); // SOX: log the revocation attempt before any state change so the intent is - // recorded even if the API call subsequently fails. + // recorded even if the API call subsequently fails. Include ManagedThreadId + // so revoke events can be correlated against the gateway-supplied + // RequestingUser scope when the host enriches Keyfactor.Logging with it + // (segregation-of-duties evidence — SOX CC1.3 / SOC2 CC1.4). _logger.LogInformation( "Revocation attempt started. " + "CARequestID={Id}, HexSerialNumber={Serial}, " + - "ReasonCode={ReasonCode}, ReasonString={ReasonString}", - caRequestID, hexSerialNumber, revocationReason, reasonString); + "ReasonCode={ReasonCode}, ReasonString={ReasonString}, " + + "ManagedThreadId={ThreadId}", + caRequestID, hexSerialNumber, revocationReason, reasonString, + System.Environment.CurrentManagedThreadId); // Verify the certificate is in a revocable state before calling the API LegacyGetCertificateResponse current; @@ -572,7 +758,7 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r "ReasonCode={ReasonCode}, ReasonString={ReasonString}", caRequestID, hexSerialNumber, current.Subject, revocationReason, reasonString); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); return (int)EndEntityStatus.REVOKED; } @@ -587,7 +773,7 @@ public async Task Synchronize( bool fullSync, CancellationToken cancelToken) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); DateTime? issuedAfter = fullSync ? (DateTime?)null : lastSync; @@ -597,8 +783,23 @@ public async Task Synchronize( int synced = 0; int skipped = 0; + int skippedWithBody = 0; // skipped records that nonetheless carried a cert body (should be 0) int errors = 0; +#if SUPPORTS_DCV + // DCV-during-sync only actually runs when DCV is enabled AND a DNS provider factory was + // injected by the host. On a gateway that doesn't supply one (e.g. IAnyCAPlugin 3.2.0 + // hosts), DCV cannot run even on a DCV-capable build — so don't run the gate or report + // attempt counts that would imply it did (issue 0003). Bounds apply only when operational. + bool dcvOperational = _config.DcvEnabled && _domainValidatorFactory != null; + int ageWindowHours = _config.DcvSyncMaxOrderAgeHours; // 0 = no age filter + int perPassCap = _config.DcvSyncMaxPerPass; // 0 = no cap + int dcvAttempted = 0, dcvSkippedAge = 0, dcvSkippedCap = 0; +#endif + + // Emit-side accounting (issue 0003): what the plugin hands to the gateway buffer. + int emittedGeneratedWithBody = 0, emittedGeneratedNoBody = 0, emittedRevoked = 0, emittedPending = 0; + try { await foreach (var cert in _client.ListCertificatesAsync( @@ -606,32 +807,171 @@ public async Task Synchronize( { cancelToken.ThrowIfCancellationRequested(); + // Local copy so we can replace it with a post-DCV refetch below + var current = cert; + try { // Skip expired certificates when IgnoreExpired is configured if (_config.IgnoreExpired - && cert.ExpiresAt.HasValue - && cert.ExpiresAt.Value < DateTime.UtcNow) + && current.ExpiresAt.HasValue + && current.ExpiresAt.Value < DateTime.UtcNow) { _logger.LogTrace( "Skipping expired certificate '{Id}' (expires {ExpiresAt:u}).", - cert.Id, cert.ExpiresAt.Value); + current.Id, current.ExpiresAt.Value); + if (!string.IsNullOrWhiteSpace(current.Certificate)) skippedWithBody++; skipped++; continue; } + int status = StatusMapper.ToRequestDisposition(current.Status); + + // Per-record trace so a sync pass is fully reconstructable from logs + // (info-level only emits start/summary). Enable Trace on this category. + _logger.LogTrace( + "Sync: processing order Id={Id}, listedStatus='{Listed}', mappedStatus={Status}, " + + "orderDate={OrderDate}, bodyInListing={HasBody}", + current.Id, current.Status, status, + current.OrderDate?.ToString("o") ?? "(none)", + !string.IsNullOrWhiteSpace(current.Certificate)); + + // Deferred DCV: pending orders (EXTERNALVALIDATION) often need DCV driven + // forward during sync — CERTInext parks fresh orders and exposes the DCV + // challenge minutes after enrollment, and scans are the only place that gets + // picked back up. But attempting DCV for EVERY pending order on EVERY pass is + // O(pending) and pathologically slow with a large/abandoned backlog (issue + // 0002). Bound it: only recently-placed orders are eligible (age window), and + // at most N per pass (cap). Aged-out / over-cap orders are emitted as pending + // and revisited on a later pass (the per-minute incremental scan keeps recent + // orders moving). Unknown order age → treat as eligible so we never starve a + // legitimately-new order. +#if SUPPORTS_DCV + if (dcvOperational && status == (int)EndEntityStatus.EXTERNALVALIDATION) + { + var decision = EvaluateDcvSyncEligibility( + current.OrderDate, DateTime.UtcNow, ageWindowHours, dcvAttempted, perPassCap); + + _logger.LogTrace( + "Sync DCV gate: Id={Id}, decision={Decision}, orderDate={OrderDate}, " + + "ageWindowHours={Age}, attemptedSoFar={Attempted}, perPassCap={Cap}", + current.Id, decision, current.OrderDate?.ToString("o") ?? "(none)", + ageWindowHours, dcvAttempted, perPassCap); + + if (decision == DcvSyncDecision.SkipByAge) + { + // Issue 0003 / SOC1 completeness: an order past the age window is no + // longer advanced by sync (it only ages further), so record its + // identity at Information — not just the aggregate count — so an + // auditor can see which orders were left parked, and when. + _logger.LogInformation( + "Sync: pending DV order aged out of the DCV-during-sync window and will " + + "not be advanced. CARequestID={Id}, OrderDate={OrderDate}, AgeWindowHours={Age}.", + current.Id, current.OrderDate?.ToString("o") ?? "(none)", ageWindowHours); + dcvSkippedAge++; + } + else if (decision == DcvSyncDecision.SkipByCap) + { + dcvSkippedCap++; + } + else + { + dcvAttempted++; + bool dcvDone = await TryRunDcvDuringSyncAsync( + current.Id, cancelToken, fastSync: true); + if (dcvDone) + { + try + { + current = await _client.GetCertificateAsync(current.Id, cancelToken); + status = StatusMapper.ToRequestDisposition(current.Status); + } + catch (Exception refetchEx) + { + _logger.LogWarning(refetchEx, + "Sync DCV completed but post-DCV refetch failed. Id={Id}", current.Id); + } + } + } + } +#endif + // Skip failed/rejected/cancelled certificates — they have no cert body - int status = StatusMapper.ToRequestDisposition(cert.Status); if (status == (int)EndEntityStatus.FAILED) { _logger.LogTrace( "Skipping certificate '{Id}' with terminal failure status '{Status}'.", - cert.Id, cert.Status); + current.Id, current.Status); + if (!string.IsNullOrWhiteSpace(current.Certificate)) skippedWithBody++; skipped++; continue; } - var record = MapToAnyCAPluginCertificate(cert); + // The order-report listing (ListCertificatesAsync) does NOT include the + // certificate body, so an already-issued order arrives here with + // current.Certificate == null. Command cannot store a record without a + // body, so issued certs were being silently dropped from sync. Refetch the + // full certificate (PEM included) for issued/revoked orders whose body is + // missing — this mirrors GetSingleRecord and the DCV-completed branch above. + // Pending (EXTERNALVALIDATION) records legitimately have no body yet and are + // left as-is. + if (string.IsNullOrWhiteSpace(current.Certificate) + && (status == (int)EndEntityStatus.GENERATED + || status == (int)EndEntityStatus.REVOKED)) + { + _logger.LogDebug( + "Sync: issued/revoked order Id={Id} has no body in the listing — refetching full certificate.", + current.Id); + // The order-report listing carries metadata (Subject/DomainName, + // ProfileId/ProductCode, OrderDate) that GetCertificateAsync (TrackOrder + + // DownloadCertificate) does NOT return. The refetch replaces `current` + // wholesale, so carry that listing metadata across or the emitted record + // loses its Subject and ProductID (ProductID feeds the Command template). + var listed = current; + try + { + current = await _client.GetCertificateAsync(current.Id, cancelToken); + current.Subject = string.IsNullOrWhiteSpace(current.Subject) ? listed.Subject : current.Subject; + current.ProfileId = string.IsNullOrWhiteSpace(current.ProfileId) ? listed.ProfileId : current.ProfileId; + current.OrderDate ??= listed.OrderDate; + 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); + } + catch (Exception fetchEx) + { + _logger.LogWarning(fetchEx, + "Sync: failed to fetch certificate body for issued order '{Id}'; " + + "emitting metadata-only record.", current.Id); + } + } + + var record = MapToAnyCAPluginCertificate(current); + + // Emit-side observability (issue 0003): account for what the plugin hands to + // the gateway buffer, broken down by status and whether a cert body is present. + // This is the boundary the plugin owns — if these counts show issued records + // emitted WITH bodies but the gateway DB lacks them, the gap is gateway-side + // persistence, not the plugin. Per-record detail is at Debug; the aggregate is + // logged at Information in the completion summary below. + bool recordHasBody = !string.IsNullOrWhiteSpace(record.Certificate); + 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( + "Sync emit: CARequestID={Id}, Status={Status}, CertBytes={CertBytes}, Subject={Subject}", + record.CARequestID, record.Status, record.Certificate?.Length ?? 0, current.Subject); + blockingBuffer.Add(record, cancelToken); synced++; } @@ -649,12 +989,43 @@ public async Task Synchronize( { _logger.LogError(ex, "Error processing certificate '{Id}' during synchronization.", cert.Id); errors++; + + // SOC1 completeness/accuracy: a sync that hits an error-rate cliff + // must report a failure, not silently 'complete' with zero useful + // records. Abort if we have at least 50 records' worth of evidence + // AND more than 25% of all records seen so far are errors. + int totalSeen = synced + skipped + errors; + if (totalSeen >= 50 && errors > totalSeen / 4) + { + _logger.LogError( + "CERTInext 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 synchronization aborted after {errors}/{totalSeen} records failed " + + "(>25% error rate). See gateway logs for the underlying CA errors."); + } } } + // Build the DCV-during-sync clause for the ACTUAL runtime state so the summary + // never implies DCV ran when it couldn't (issue 0003 / SOC2 CC7.3 accuracy). + 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 synchronization complete. Synced={Synced}, Skipped={Skipped}, Errors={Errors}", - synced, skipped, errors); + "CERTInext synchronization complete. Synced={Synced}, Skipped={Skipped} (withBody={SkippedWithBody}), " + + "Errors={Errors}. Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, " + + "GeneratedNoBody={GenNoBody}, Revoked={Revoked}, Pending={Pending}. {DcvClause}", + synced, skipped, skippedWithBody, errors, + emittedGeneratedWithBody, emittedGeneratedNoBody, emittedRevoked, emittedPending, + dcvClause); } catch (OperationCanceledException) { @@ -669,13 +1040,45 @@ public async Task Synchronize( blockingBuffer.CompleteAdding(); } - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- + /// The DCV-during-sync gate outcome for a single pending order (issue 0002). + 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). + /// Pure/stateless so it is unit-testable without the DCV machinery. + /// + /// Rules (checked in order): + /// - Age: when > 0, only orders placed within that + /// window are eligible. A missing is treated as eligible + /// so a legitimately-new order is never starved by unknown age. + /// - Cap: when > 0, at most that many orders are attempted + /// per pass; once reaches it, the rest are deferred. + /// A value of 0 for either bound disables that bound. + /// + internal static DcvSyncDecision EvaluateDcvSyncEligibility( + DateTime? orderDateUtc, DateTime nowUtc, int ageWindowHours, int attemptedSoFar, int perPassCap) + { + bool eligibleByAge = ageWindowHours <= 0 + || !orderDateUtc.HasValue + || (nowUtc - orderDateUtc.Value).TotalHours <= ageWindowHours; + if (!eligibleByAge) + return DcvSyncDecision.SkipByAge; + + bool eligibleByCap = perPassCap <= 0 || attemptedSoFar < perPassCap; + if (!eligibleByCap) + return DcvSyncDecision.SkipByCap; + + return DcvSyncDecision.Attempt; + } + /// /// Handles New and Reissue enrollment flows by submitting a fresh certificate /// request to CERTInext. @@ -686,6 +1089,7 @@ private async Task EnrollNewAsync( Dictionary san, EnrollmentParams ep) { + _logger.MethodEntry(LogLevel.Debug); var enrollReq = new EnrollCertificateRequest { ProfileId = ep.ProfileId, @@ -701,6 +1105,72 @@ private async Task EnrollNewAsync( var enrollResp = await _client.EnrollCertificateAsync(enrollReq); +#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)) + { + // 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 + + _logger.MethodExit(LogLevel.Debug); return BuildEnrollmentResult(enrollResp, ep.AutoApprove); } @@ -721,6 +1191,14 @@ private async Task RenewOrReissueAsync( 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); + if (string.IsNullOrWhiteSpace(priorCertSn)) { // SOC2 CC7.2: log policy-relevant decisions at Information so they survive @@ -830,6 +1308,566 @@ private async Task RenewOrReissueAsync( } } + // --------------------------------------------------------------------------- + // 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. 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. + /// + 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.) + + /// + /// 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( + "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. 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). + /// + /// 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) + { + 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. + // + // 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) + { + if (string.IsNullOrWhiteSpace(domain)) + throw new InvalidOperationException( + $"TrackOrder returned a blank domain key in domainVerification for order '{orderNumber}'. " + + "Cannot proceed with DCV."); + + // Allow standard FQDN characters plus wildcard prefix (*.example.com) + if (!System.Text.RegularExpressions.Regex.IsMatch(domain, @"^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$")) + { + _logger.LogError( + "DCV domain name failed validation and will not be processed. OrderNumber={OrderNumber}, Domain={Domain}", + orderNumber, domain); + throw new InvalidOperationException( + $"TrackOrder returned an invalid domain name '{domain}' in domainVerification for order '{orderNumber}'. " + + "Domain names must conform to FQDN syntax."); + } + } + + 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)>(); + + // Stage DNS TXT records for all pending domains + foreach (var (domain, _) in pendingDomains) + { + GetDcvResponse dcvResp; + try + { + dcvResp = await _client.GetDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct); + } + catch (Exception ex) when (IsDcvNotYetReady(ex)) + { + // CERTInext occasionally exposes the DCV slot in TrackOrder (so + // domainVerification is populated and dcvStatus="0") before the GetDcv + // endpoint will accept calls for that order — observed as EMS-956 + // "Invalid Request for this API" for several hours after enrollment. + // 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. + _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; + } + catch (Exception ex) + { + _logger.LogError(ex, "GetDcv failed for order {OrderNumber} domain {Domain}", orderNumber, domain); + throw; + } + + string token = dcvResp.DcvDetails?.Token; + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException( + $"GetDcv returned no token for order '{orderNumber}' domain '{domain}'."); + + string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate) + ? Constants.Dcv.DefaultTxtRecordTemplate + : _config.DcvTxtRecordTemplate; + string hostname = string.Format(template, domain); + + 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."); + + _logger.LogInformation( + "Staging DNS TXT record for DCV. OrderNumber={OrderNumber}, Domain={Domain}, Hostname={Hostname}", + orderNumber, domain, hostname); + + var stageResult = await validator.StageValidation(hostname, token, ct); + if (!stageResult.Success) + throw new InvalidOperationException( + $"Failed to stage DNS validation for '{domain}': {stageResult.ErrorMessage}"); + + stagedValidations.Add((domain, hostname, validator)); + } + + if (stagedValidations.Count == 0) + return false; + + try + { + // 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); + + foreach (var (domain, hostname, _) in stagedValidations) + { + _logger.LogInformation( + "Triggering CERTInext DCV verification. OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, 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, stagedValidations.Select(s => s.domain).ToList(), ct); + } + finally + { + // Always clean up staged DNS records — even on failure + foreach (var (domain, hostname, validator) in stagedValidations) + { + try + { + await validator.CleanupValidation(hostname, ct); + _logger.LogInformation( + "DNS TXT record cleaned up. Domain={Domain}, Hostname={Hostname}", domain, hostname); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to clean up DNS TXT record. Domain={Domain}, Hostname={Hostname}", domain, hostname); + } + } + } + + return true; + } +#endif + + /// + /// Polls GetCertificateAsync until either (a) the certificate reaches a terminal + /// state (issued or rejected) or (b) the configured DcvWaitForIssuanceSeconds + /// budget expires. Returns the final response on success, or null if all polls + /// failed (so callers fall back to the pending result they already have). + /// + /// CERTInext's issuance pipeline is asynchronous on their side: after the plugin's + /// VerifyDcv triggers and the per-domain DCV is confirmed, the cert generation step + /// finishes a few seconds later. Without this poll the plugin would catch the cert + /// in pending state and return it that way, forcing the gateway to wait for the next + /// sync cycle. + /// + private async Task WaitForIssuanceAfterDcvAsync( + string orderNumber, CancellationToken ct) + { + int waitBudgetSeconds = _config.GetEffectiveDcvWaitForIssuanceSeconds(); + + // Fixed 3-second poll interval. CERTInext's post-DCV issuance step typically + // completes within 5–15s; polling more aggressively would just add API load, + // and polling more slowly would push the typical-case latency closer to the + // budget ceiling. Decoupled from DcvPropagationDelaySeconds (which is for DNS + // propagation, a different concern) so admins tuning DNS settings don't + // accidentally make post-DCV polling chunky. + int pollIntervalSeconds = 3; + DateTime deadline = DateTime.UtcNow.AddSeconds(Math.Max(0, waitBudgetSeconds)); + LegacyGetCertificateResponse last = null; + + // Admin opt-out: budget <= 0 means "don't wait, let sync pick the cert up". + // Short-circuit before any API call so the gateway doesn't pay a TrackOrder + + // optional DownloadCertificate round trip per Enroll when the admin has + // explicitly disabled the wait. + if (waitBudgetSeconds <= 0) + { + _logger.LogDebug( + "Post-DCV issuance wait disabled (DcvWaitForIssuanceSeconds<=0). " + + "Order {OrderNumber} will be picked up on the next sync cycle.", + orderNumber); + return null; + } + + int attempt = 0; + while (true) + { + attempt++; + ct.ThrowIfCancellationRequested(); + try + { + last = await _client.GetCertificateAsync(orderNumber, ct); + } + catch (Exception ex) + { + // Distinguish first-call failure (no result to return, sync must pick up) + // from later-poll failure (we have a prior pending result that the caller + // can use as a fallback). Without this distinction a repeated first-call + // failure would look identical to a working-but-always-pending enroll. + _logger.LogWarning(ex, + "Post-DCV GetCertificate failed for order {OrderNumber} (attempt {Attempt}). " + + "Returning {Outcome}; sync will pick up the cert later.", + orderNumber, attempt, last == null ? "pending fallback (no prior result)" : "prior pending result"); + return last; + } + + int disposition = StatusMapper.ToRequestDisposition(last.Status); + if (disposition == (int)EndEntityStatus.GENERATED + || disposition == (int)EndEntityStatus.REVOKED + || disposition == (int)EndEntityStatus.FAILED) + { + return last; + } + + if (waitBudgetSeconds <= 0 || DateTime.UtcNow >= deadline) + { + _logger.LogInformation( + "Post-DCV issuance not complete within {Budget}s for order {OrderNumber}. " + + "Returning pending result; sync will pick up the cert later.", + waitBudgetSeconds, orderNumber); + return last; + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(pollIntervalSeconds), ct); + } + catch (OperationCanceledException) + { + return last; + } + } + } + + /// + /// Polls until every domain in + /// reaches dcvStatus=1 (verified) or a terminal + /// failure state (rejected/cancelled), or is cancelled. + /// Called after VerifyDcvAsync to ensure CERTInext has completed its async + /// DNS lookup before TXT records are cleaned up. + /// + private async Task WaitForDcvVerificationAsync(string orderNumber, IReadOnlyList domains, CancellationToken ct) + { + if (domains.Count == 0) return; + + var pending = new HashSet(domains, StringComparer.OrdinalIgnoreCase); + int pollSeconds = Math.Max(1, _config.DcvPropagationDelaySeconds); + + // Defense-in-depth deadline: SOX CC7.3 requires every wait to be bounded. + // The caller passes a `ct` derived from a CancellationTokenSource that already + // cancels after `DcvTimeoutMinutes`, so this method is bounded via that path. + // We add an explicit internal deadline so a future refactor breaking the + // cancellation chain (e.g. accidentally passing CancellationToken.None) can't + // make this loop unbounded — it would still exit on the deadline below. + var verificationDeadline = DateTime.UtcNow.AddMinutes(_config.GetEffectiveDcvTimeoutMinutes()); + + while (pending.Count > 0 && !ct.IsCancellationRequested) + { + if (DateTime.UtcNow >= verificationDeadline) + { + _logger.LogWarning( + "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)); + return; + } + + await Task.Delay(TimeSpan.FromSeconds(pollSeconds), ct); + + TrackOrderResponse poll; + try { poll = await _client.TrackOrderAsync(orderNumber, ct); } + catch (Exception ex) + { + _logger.LogWarning(ex, "TrackOrder polling failed during DCV wait. OrderNumber={OrderNumber}", orderNumber); + return; + } + + var entries = poll.OrderDetails?.DomainVerification?.GetDomainEntries() + ?? new Dictionary(); + + // Check for order-level terminal failure (cancelled/rejected) + if (poll.OrderDetails?.OrderStatusId is "4" or "5") + { + _logger.LogWarning( + "Order {OrderNumber} reached terminal failure state (OrderStatusId={Status}) during DCV wait. TXT records will be cleaned up.", + orderNumber, poll.OrderDetails.OrderStatusId); + return; + } + + foreach (var domain in domains) + { + if (!pending.Contains(domain)) continue; + if (!entries.TryGetValue(domain, out var detail)) continue; + + if (string.Equals(detail.DcvStatus, Constants.Dcv.StatusValidated, StringComparison.Ordinal)) + { + _logger.LogInformation("DCV verified by CERTInext. OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, 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); + pending.Remove(domain); + } + } + } + } + /// /// Converts a CERTInext API enrollment/renewal response into the /// expected by the AnyCA gateway. @@ -972,6 +2010,9 @@ 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. + /// + /// Implemented with BouncyCastle (per the project's crypto policy: all certificate + /// and key handling goes through BouncyCastle, never BCL System.Security.Cryptography). /// private static string ExtractSerialFromPem(string pem) { @@ -989,11 +2030,25 @@ private static string ExtractSerialFromPem(string pem) return "(empty-pem)"; byte[] der = Convert.FromBase64String(b64); - using var cert = new System.Security.Cryptography.X509Certificates.X509Certificate2(der); - return cert.SerialNumber; + var parser = new Org.BouncyCastle.X509.X509CertificateParser(); + var cert = parser.ReadCertificate(der); + if (cert == null) + return "(parse-error)"; + // Match X509Certificate2.SerialNumber's format precisely: uppercase hex, + // byte-per-byte, *preserving* leading-zero bytes (e.g. serial bytes + // 0A 12 34 56 → "0A123456", not "A123456"). BouncyCastle's + // BigInteger.ToString(16) drops the leading-zero nibble, which would + // break audit-log correlation against Command's stored serial. Convert + // the unsigned-magnitude byte array to hex directly instead. + byte[] serialBytes = cert.SerialNumber.ToByteArrayUnsigned(); + return Convert.ToHexString(serialBytes).ToUpperInvariant(); } - catch + catch (Exception ex) { + // SOC2 CC7.2: never let audit-log generation throw, but log the suppression + // at Debug so an auditor diagnosing missing serial numbers can see the cause. + LogHandler.GetClassLogger(typeof(CERTInextCAPlugin)) + .LogDebug(ex, "ExtractSerialFromPem suppressed parse failure"); return "(parse-error)"; } } diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index acd3f81..43d0537 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -46,10 +46,61 @@ public static Dictionary GetCAConnectorAnnotations() [Constants.Config.GroupNumber] = new PropertyConfigInfo { Comments = "OPTIONAL: CERTInext group (delegation) number. " + - "When set, it is included in GetProductDetails requests so the full " + - "product list is returned. Some sandbox accounts require this to avoid " + - "receiving an empty product list. Available in the CERTInext portal under " + - "Delegation → Groups.", + "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.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, + [Constants.Config.OrganizationNumber] = new PropertyConfigInfo + { + Comments = "STRONGLY RECOMMENDED for OV/EV and faster DV issuance: numeric " + + "CERTInext organization number for a pre-vetted organization (e.g. " + + "your company's pre-vetted entry). When set, every SSL order is submitted " + + "with `organizationDetails.preVetting=\"1\"` and the configured " + + "`organizationNumber`, telling CERTInext to skip the manual " + + "organization-vetting queue. Without this value, orders are placed without " + + "any organizationDetails block and CERTInext may park them in " + + "`Pending System RA` for extended manual review (observed: tens of hours). " + + "Available in the CERTInext portal under Organizations → " + + "Pre-vetted Organizations.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, + [Constants.Config.TechnicalContactName] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Name sent in the `technicalPointOfContact.tpcName` field of every " + + "SSL order. Defaults to the configured RequestorName when blank. " + + "Some product configurations require a TPoC to be present; omitting it can " + + "cause CERTInext to park orders awaiting manual completion of the field.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, + [Constants.Config.TechnicalContactEmail] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Email sent in the `technicalPointOfContact.tpcEmail` field of every " + + "SSL order. Defaults to the configured RequestorEmail when blank.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, + [Constants.Config.TechnicalContactIsdCode] = new PropertyConfigInfo + { + Comments = "OPTIONAL: International dialing code for the TPoC phone number. " + + "Defaults to the configured RequestorIsdCode when blank.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, + [Constants.Config.TechnicalContactMobileNumber] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Mobile number for the TPoC (digits only). " + + "Defaults to the configured RequestorMobileNumber when blank.", Hidden = false, DefaultValue = string.Empty, Type = "String" @@ -147,6 +198,57 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = string.Empty, Type = "String" }, + [Constants.Config.AccountingModel] = new PropertyConfigInfo + { + Comments = "OPTIONAL: CERTInext billing model sent in `orderDetails.accountingModel`. " + + "\"2\" = credit-based (most accounts, default). \"1\" = cash model.", + Hidden = false, + DefaultValue = "2", + Type = "String" + }, + [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\".", + Hidden = false, + DefaultValue = "0", + Type = "String" + }, + [Constants.Config.SubscriptionValidityYears] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Default validity in years for SSL orders. \"1\", \"2\", or \"3\". " + + "Override per template via the ValidityYears product parameter. Default: \"1\".", + Hidden = false, + DefaultValue = "1", + Type = "String" + }, + [Constants.Config.SubscriptionAutoRenew] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Whether CERTInext should auto-renew certificates issued through " + + "this connector. \"0\" = disabled (recommended — renewal is driven by Keyfactor " + + "Command), \"1\" = enabled. Default: \"0\".", + Hidden = false, + DefaultValue = "0", + Type = "String" + }, + [Constants.Config.SubscriptionRenewCriteriaDays] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Days before expiry at which CERTInext auto-renews (only honored when " + + "SubscriptionAutoRenew = \"1\"). Typical values: \"30\" or \"60\". Default: \"30\".", + Hidden = false, + DefaultValue = "30", + Type = "String" + }, + [Constants.Config.AutoSecureWww] = new PropertyConfigInfo + { + Comments = "OPTIONAL: If \"1\", CERTInext automatically adds the `www.` variant of the " + + "primary domain as an additional SAN. \"0\" = use only the CN/SANs supplied " + + "with the CSR. Default: \"0\".", + Hidden = false, + DefaultValue = "0", + Type = "String" + }, [Constants.Config.IgnoreExpired] = new PropertyConfigInfo { Comments = "If true, expired certificates will be skipped during synchronization. Default: false.", @@ -169,6 +271,93 @@ public static Dictionary GetCAConnectorAnnotations() Hidden = false, DefaultValue = true, Type = "Boolean" + }, + [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.", + Hidden = false, + DefaultValue = false, + Type = "Boolean" + }, + [Constants.Config.DcvTxtRecordTemplate] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Format string for the DNS TXT record hostname used during DCV. " + + "{0} is replaced with the domain name being validated. " + + $"Default: {Constants.Dcv.DefaultTxtRecordTemplate}", + Hidden = false, + DefaultValue = Constants.Dcv.DefaultTxtRecordTemplate, + Type = "String" + }, + [Constants.Config.DcvPropagationDelaySeconds] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext " + + "to verify it. Increase for zones with slow propagation. Default: 30.", + Hidden = false, + DefaultValue = 30, + Type = "Number" + }, + [Constants.Config.DcvTimeoutMinutes] = new PropertyConfigInfo + { + Comments = $"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 {Constants.Config.DcvTimeoutMinutesEnvVar} " + + $"environment variable; the env var takes precedence when both are set. Default: 10.", + Hidden = false, + DefaultValue = 10, + Type = "Number" + }, + [Constants.Config.DcvWaitForChallengeSeconds] = new PropertyConfigInfo + { + Comments = "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 {Constants.Config.DcvWaitForChallengeSecondsEnvVar} " + + "environment variable; the env var takes precedence when both are set. Default: 60.", + Hidden = false, + DefaultValue = 60, + Type = "Number" + }, + [Constants.Config.DcvWaitForIssuanceSeconds] = new PropertyConfigInfo + { + Comments = "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 {Constants.Config.DcvWaitForIssuanceSecondsEnvVar} " + + "environment variable; the env var takes precedence when both are set. Default: 60.", + Hidden = false, + DefaultValue = 60, + Type = "Number" + }, + [Constants.Config.DcvSyncMaxOrderAgeHours] = new PropertyConfigInfo + { + Comments = "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: {Constants.Dcv.DefaultSyncMaxOrderAgeHours}.", + Hidden = false, + DefaultValue = Constants.Dcv.DefaultSyncMaxOrderAgeHours, + Type = "Number" + }, + [Constants.Config.DcvSyncMaxPerPass] = new PropertyConfigInfo + { + Comments = "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: {Constants.Dcv.DefaultSyncMaxPerPass}.", + Hidden = false, + DefaultValue = Constants.Dcv.DefaultSyncMaxPerPass, + Type = "Number" } }; } @@ -318,12 +507,27 @@ public class CERTInextConfig /// /// Optional CERTInext group (delegation) number. When set, it is passed in /// the productDetails.groupNumber field of GetProductDetails - /// requests so that the account's full product list is returned. Some sandbox - /// accounts return an empty product list if this field is omitted. + /// requests AND in the delegationInformation.groupNumber field of every + /// SSL order body so the order is routed to the correct account group. Some + /// accounts queue orders for extra review when this field is omitted. /// [JsonPropertyName("GroupNumber")] public string GroupNumber { get; set; } = string.Empty; + /// + /// CERTInext organization number for a pre-vetted organization (e.g. the customer's + /// company). When set, every SSL order is submitted with + /// organizationDetails.preVetting="1" and the configured + /// organizationNumber, telling CERTInext to skip the manual organization + /// vetting queue. Strongly recommended for OV/EV products; significantly speeds + /// up DV issuance because CERTInext otherwise parks orders in Pending System RA + /// for extended manual review (observed tens of hours on the sandbox). + /// Empty by default — the plugin omits the organizationDetails block when + /// this is unset, preserving prior behavior. + /// + [JsonPropertyName("OrganizationNumber")] + public string OrganizationNumber { get; set; } = string.Empty; + // ----------------------------------------------------------------------- // Authentication // ----------------------------------------------------------------------- @@ -405,6 +609,56 @@ public class CERTInextConfig [JsonPropertyName("DefaultProductCode")] public string DefaultProductCode { get; set; } = string.Empty; + // ----------------------------------------------------------------------- + // Technical point-of-contact — populated into technicalPointOfContact on SSL orders. + // When any field is blank, the corresponding Requestor* default is used. + // ----------------------------------------------------------------------- + + /// Technical contact name. Defaults to when blank. + [JsonPropertyName("TechnicalContactName")] + public string TechnicalContactName { get; set; } = string.Empty; + + /// Technical contact email. Defaults to when blank. + [JsonPropertyName("TechnicalContactEmail")] + public string TechnicalContactEmail { get; set; } = string.Empty; + + /// Technical contact ISD code. Defaults to when blank. + [JsonPropertyName("TechnicalContactIsdCode")] + public string TechnicalContactIsdCode { get; set; } = string.Empty; + + /// Technical contact mobile number. Defaults to when blank. + [JsonPropertyName("TechnicalContactMobileNumber")] + public string TechnicalContactMobileNumber { get; set; } = string.Empty; + + // ----------------------------------------------------------------------- + // SSL order body defaults — every value matches a CERTInext-documented field + // and is overridable per-connector via the gateway admin UI. + // ----------------------------------------------------------------------- + + /// CERTInext billing model ("2" credit, "1" cash). Default "2". + [JsonPropertyName("AccountingModel")] + public string AccountingModel { get; set; } = "2"; + + /// "1" = enable lifecycle emails to requestor, "0" = silent (default). + [JsonPropertyName("EmailNotifications")] + public string EmailNotifications { get; set; } = "0"; + + /// Default validity in years sent in subscriptionDetails. "1", "2", or "3". Default "1". + [JsonPropertyName("SubscriptionValidityYears")] + public string SubscriptionValidityYears { get; set; } = "1"; + + /// "0" = disable CERTInext-side auto-renew (recommended — renewal is driven by Command). "1" = enable. + [JsonPropertyName("SubscriptionAutoRenew")] + public string SubscriptionAutoRenew { get; set; } = "0"; + + /// Days before expiry at which CERTInext auto-renews (only honored when SubscriptionAutoRenew="1"). + [JsonPropertyName("SubscriptionRenewCriteriaDays")] + public string SubscriptionRenewCriteriaDays { get; set; } = "30"; + + /// "1" = let CERTInext auto-add the www. variant, "0" = use only the supplied CN/SANs (default). + [JsonPropertyName("AutoSecureWww")] + public string AutoSecureWww { get; set; } = "0"; + // ----------------------------------------------------------------------- // Sync / behaviour // ----------------------------------------------------------------------- @@ -417,5 +671,116 @@ public class CERTInextConfig [JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true; + + // ----------------------------------------------------------------------- + // DCV — domain control validation via DNS provider plugins + // ----------------------------------------------------------------------- + + /// + /// 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. + /// + [JsonPropertyName("DcvEnabled")] + public bool DcvEnabled { get; set; } = false; + + /// + /// Format string for the TXT record hostname. {0} is replaced with the domain. + /// Default: _emsign-validation.{0}. + /// + [JsonPropertyName("DcvTxtRecordTemplate")] + public string DcvTxtRecordTemplate { get; set; } = Constants.Dcv.DefaultTxtRecordTemplate; + + /// + /// Seconds to wait after publishing the DNS TXT record before calling VerifyDcv. + /// Default: 30. + /// + [JsonPropertyName("DcvPropagationDelaySeconds")] + public int DcvPropagationDelaySeconds { get; set; } = 30; + + /// + /// Maximum minutes for the entire DCV flow before the enrollment is cancelled. + /// Overridden by the CERTINEXT_DCV_TIMEOUT_MINUTES environment variable when set. + /// Default: 10. + /// + [JsonPropertyName("DcvTimeoutMinutes")] + public int DcvTimeoutMinutes { get; set; } = 10; + + /// + /// Seconds the plugin will poll inside Enroll() waiting for CERTInext to populate + /// domainVerification in TrackOrder. Under concurrent load the slot can + /// take a few seconds to appear after GenerateOrderSSL returns; without this + /// wait the plugin's initial single-shot check sees null and skips DCV. + /// Set to 0 to disable the wait (preserving the single-check behaviour). + /// Overridden by CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS when set. Default: 60. + /// + [JsonPropertyName("DcvWaitForChallengeSeconds")] + public int DcvWaitForChallengeSeconds { get; set; } = 60; + + /// + /// Seconds the plugin will poll GetCertificate inside Enroll() after DCV + /// verifies, waiting for CERTInext to finish generating the certificate. CERTInext + /// issuance is async — DCV may be verified but the cert PEM isn't yet available. + /// Set to 0 to disable the wait (preserving the single-fetch behaviour, where + /// the cert is picked up on the next sync cycle). Overridden by + /// CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS when set. Default: 60. + /// + [JsonPropertyName("DcvWaitForIssuanceSeconds")] + public int DcvWaitForIssuanceSeconds { get; set; } = 60; + + /// + /// During synchronization, only pending DV orders younger than this many hours are + /// eligible for DCV completion. Bounds a sync pass against a large backlog of old, + /// never-completing pending orders (issue 0002). 0 disables the age filter. + /// Default: 24. + /// + [JsonPropertyName("DcvSyncMaxOrderAgeHours")] + public int DcvSyncMaxOrderAgeHours { get; set; } = Constants.Dcv.DefaultSyncMaxOrderAgeHours; + + /// + /// Maximum number of pending DV orders the plugin attempts to drive through DCV in a + /// single sync pass (issue 0002). Bounds per-pass cost regardless of backlog size; the + /// remainder are reported pending and revisited on a later pass. 0 disables the cap. + /// Default: 50. + /// + [JsonPropertyName("DcvSyncMaxPerPass")] + public int DcvSyncMaxPerPass { get; set; } = Constants.Dcv.DefaultSyncMaxPerPass; + + /// + /// Returns the effective DCV timeout, preferring the environment variable over the + /// config field so operators can adjust the ceiling without a connector reconfiguration. + /// + public int GetEffectiveDcvTimeoutMinutes() + { + var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvTimeoutMinutesEnvVar); + if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal > 0) + return envVal; + return DcvTimeoutMinutes > 0 ? DcvTimeoutMinutes : 10; + } + + /// + /// Returns the effective wait for the DCV challenge to appear in TrackOrder, preferring + /// the env var so operators can tune without re-saving the connector. A value of 0 + /// (either field or env var) disables the wait entirely. + /// + public int GetEffectiveDcvWaitForChallengeSeconds() + { + var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvWaitForChallengeSecondsEnvVar); + if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal >= 0) + return envVal; + return DcvWaitForChallengeSeconds >= 0 ? DcvWaitForChallengeSeconds : 60; + } + + /// + /// Returns the effective post-DCV wait for cert issuance, preferring the env var. + /// A value of 0 disables the wait. + /// + public int GetEffectiveDcvWaitForIssuanceSeconds() + { + var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvWaitForIssuanceSecondsEnvVar); + if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal >= 0) + return envVal; + return DcvWaitForIssuanceSeconds >= 0 ? DcvWaitForIssuanceSeconds : 60; + } } } diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 70cfa5d..255b65a 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -9,7 +9,6 @@ using System.Collections.Generic; using System.Net; using System.Runtime.CompilerServices; -using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; @@ -161,9 +160,12 @@ public async Task PingAsync(CancellationToken ct = default) var result = DeserializeOrThrow(resp, "validate credentials"); if (result.Meta != null && !result.Meta.IsSuccess) { - Logger.LogError( - "CERTInext ValidateCredentials returned failure. ErrorCode={ErrorCode}, ErrorMessage={ErrorMsg}", - result.Meta.ErrorCode, result.Meta.ErrorMessage); + // Authentication-failure-shaped event: log at Error so SOX-required + // SIEM rules on authentication failures fire. Every other meta-failure + // call site logs at the LogApiFailure default (Warning). + LogApiFailure("ValidateCredentials", resp, + result.Meta.ErrorCode, result.Meta.ErrorMessage, + level: LogLevel.Error); throw new Exception( $"CERTInext credential validation failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " + "See gateway logs for details."); @@ -188,32 +190,92 @@ public async Task PlaceOrderAsync( "Submitting order to CERTInext. ProductCode={ProductCode}", request.OrderDetails?.ProductCode); - var req = new RestRequest(Constants.Api.GenerateOrderSslPath, Method.Post); - req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions())); + GenerateOrderResponse result = null; + RestResponse resp = null; + // Cumulative backoff time across all rate-limit retries this call. Emitted + // on the success branch so an operator scraping gateway logs for rate-limit + // pressure (SOC2 CC7.2 anomaly-detection) can correlate by single log line + // rather than threading per-attempt warnings by OrderNumber. + double totalRateLimitBackoffSeconds = 0.0; + + // Issue #8 rate-limit retry: the sandbox returns "Inactive Account User." + // as a generic error string for several conditions, including burst-rate-limit + // rejection. Empirically this resolves within seconds; auto-retrying lets a + // transient burst limit hit transparently. After RateLimitMaxAttempts the + // original exception is propagated unchanged so a genuinely-inactive account + // surfaces as the same operator-facing failure today. + for (int attempt = 1; ; attempt++) + { + // Refresh the request body's meta block on every retry — txn must be + // unique per call (CERTInext rejects duplicate txns), and a fresh ts/txn + // gives the CA a clean canary for whether the limiter has cleared. + if (attempt > 1) + request.Meta = await BuildMetaAsync(ct); - var sw = System.Diagnostics.Stopwatch.StartNew(); - var resp = await ExecuteWithRetryAsync(req, ct); - sw.Stop(); + var req = new RestRequest(Constants.Api.GenerateOrderSslPath, Method.Post); + req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions())); - Logger.LogInformation( - "CERTInext API call: Method=POST, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}, AuthMode={AuthMode}", - Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds, _config.AuthMode); + var sw = System.Diagnostics.Stopwatch.StartNew(); + resp = await ExecuteWithRetryAsync(req, ct); + sw.Stop(); - if (resp.StatusCode == HttpStatusCode.Unauthorized || resp.StatusCode == HttpStatusCode.Forbidden) - { - Logger.LogError( - "PlaceOrder API authentication failure. HttpStatus={Status}, AuthMode={AuthMode}", - (int)resp.StatusCode, _config.AuthMode); - throw new Exception( - $"Authentication failure during certificate order. HTTP {(int)resp.StatusCode}. See gateway logs for details."); - } + Logger.LogInformation( + "CERTInext API call: Method=POST, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}, AuthMode={AuthMode}, RateLimitRetryAttempt={Attempt}", + Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds, _config.AuthMode, attempt); + + if (resp.StatusCode == HttpStatusCode.Unauthorized || resp.StatusCode == HttpStatusCode.Forbidden) + { + Logger.LogError( + "PlaceOrder API authentication failure. HttpStatus={Status}, AuthMode={AuthMode}", + (int)resp.StatusCode, _config.AuthMode); + throw new Exception( + $"Authentication failure during certificate order. HTTP {(int)resp.StatusCode}. See gateway logs for details."); + } - var result = DeserializeOrThrow(resp, "place order"); + result = DeserializeOrThrow(resp, "place order"); - if (result.Meta != null && !result.Meta.IsSuccess) - throw new Exception( - $"CERTInext order failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " + - "See gateway logs for details."); + if (result.Meta != null && !result.Meta.IsSuccess) + { + LogApiFailure(Constants.Api.GenerateOrderSslPath, resp, + result.Meta.ErrorCode, result.Meta.ErrorMessage); + + // Auto-retry the documented rate-limit surface up to RateLimitMaxAttempts. + if (IsRateLimitSurface(result.Meta.ErrorMessage) && attempt < RateLimitMaxAttempts) + { + double waitSeconds = ComputeRateLimitBackoffSeconds(attempt); + totalRateLimitBackoffSeconds += waitSeconds; + Logger.LogWarning( + "PlaceOrder hit rate-limit-shaped error \"{ErrorMessage}\" (attempt {Attempt}/{Max}). " + + "Backing off {WaitSeconds:F1}s before retrying. See Troubleshooting in README for context.", + result.Meta.ErrorMessage, attempt, RateLimitMaxAttempts, waitSeconds); + try + { + await Task.Delay(TimeSpan.FromSeconds(waitSeconds), ct); + } + catch (OperationCanceledException) + { + throw; + } + continue; // retry + } + + throw new Exception( + $"CERTInext order failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " + + "See gateway logs for details."); + } + + // Success — if we retried, emit a single summary line so the rate-limit + // pressure is correlatable per-call without joining the per-attempt + // warnings by OrderNumber. (SOC2 CC7.2 anomaly-detection enablement.) + if (attempt > 1) + { + Logger.LogInformation( + "PlaceOrder succeeded after rate-limit retries. OrderNumber={OrderNumber}, " + + "RateLimitRetryCount={RetryCount}, TotalBackoffSeconds={BackoffSeconds:F1}", + result.OrderDetails?.OrderNumber, attempt - 1, totalRateLimitBackoffSeconds); + } + break; // success + } Logger.LogInformation( "CERTInext order placed. OrderNumber={OrderNumber}, RequestNumber={RequestNumber}", @@ -246,7 +308,10 @@ public async Task SubmitCsrAsync(SubmitCsrRequest request, CancellationToken ct Constants.Api.SubmitCsrPath, (int)resp.StatusCode, sw.ElapsedMilliseconds, _config.AuthMode); if (!resp.IsSuccessful) + { + LogApiFailure(Constants.Api.SubmitCsrPath, resp); throw new Exception($"CERTInext SubmitCSR failed. HTTP {(int)resp.StatusCode}. See gateway logs for details."); + } Logger.MethodExit(LogLevel.Trace); } @@ -293,6 +358,8 @@ public async Task TrackOrderAsync(string orderNumber, Cancel // A meta status of "0" with errorCode EMS-913 or similar means the order was not found if (result.Meta != null && !result.Meta.IsSuccess) { + LogApiFailure($"{Constants.Api.TrackOrderPath} {orderNumber}", resp, + result.Meta.ErrorCode, result.Meta.ErrorMessage); if (result.Meta.ErrorCode != null && (result.Meta.ErrorCode.StartsWith("EMS-9") || result.Meta.ErrorMessage?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)) { @@ -344,8 +411,12 @@ public async Task DownloadCertificateAsync(string orderN var result = DeserializeOrThrow(resp, $"download certificate {orderNumber}"); if (result.Meta != null && !result.Meta.IsSuccess) + { + LogApiFailure($"{Constants.Api.GetCertificatePath} {orderNumber}", resp, + result.Meta.ErrorCode, result.Meta.ErrorMessage); throw new Exception( $"CERTInext GetCertificate failed for order '{orderNumber}': {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}."); + } Logger.MethodExit(LogLevel.Trace); return result; @@ -402,6 +473,9 @@ public async Task RevokeOrderAsync(RevokeOrderRequest request, CancellationToken var revResp = JsonSerializer.Deserialize(resp.Content, GetJsonOptions()); if (revResp?.Meta != null && !revResp.Meta.IsSuccess) { + LogApiFailure( + $"{Constants.Api.RevokeOrderPath} {request.RevocationDetails?.OrderNumber}", + resp, revResp.Meta.ErrorCode, revResp.Meta.ErrorMessage); throw new Exception( $"CERTInext RevokeOrder returned failure for order " + $"'{request.RevocationDetails?.OrderNumber}': {revResp.Meta.ErrorMessage ?? revResp.Meta.ErrorCode}."); @@ -840,6 +914,145 @@ public async Task> GetProfilesAsync(CancellationToken ct = def return profiles; } + // --------------------------------------------------------------------------- + // ICERTInextClient — DCV methods + // --------------------------------------------------------------------------- + + /// + public async Task GetDcvAsync( + string orderNumber, + string domainName, + string dcvMethod, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + + var body = new GetDcvRequest + { + Meta = await BuildMetaAsync(ct), + DcvDetails = new DcvRequestDetails + { + RequestorEmail = _config.RequestorEmail, + OrderNumber = orderNumber, + DomainName = domainName, + DcvMethod = dcvMethod + } + }; + + var req = new RestRequest(Constants.Api.GetDcvPath, Method.Post); + req.AddJsonBody(JsonSerializer.Serialize(body, GetJsonOptions())); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var resp = await ExecuteWithRetryAsync(req, ct); + sw.Stop(); + + Logger.LogInformation( + "CERTInext API call: Method=POST, Path={Path}, OrderNumber={OrderNumber}, Domain={Domain}, HttpStatus={Status}, LatencyMs={Latency}", + Constants.Api.GetDcvPath, orderNumber, domainName, (int)resp.StatusCode, sw.ElapsedMilliseconds); + + if (resp.StatusCode == HttpStatusCode.Unauthorized || resp.StatusCode == HttpStatusCode.Forbidden) + { + Logger.LogError( + "GetDcv API authentication failure. OrderNumber={OrderNumber}, Domain={Domain}, HttpStatus={Status}", + orderNumber, domainName, (int)resp.StatusCode); + throw new Exception( + $"Authentication failure calling GetDcv for order '{orderNumber}' domain '{domainName}'. HTTP {(int)resp.StatusCode}. See gateway logs for details."); + } + + var result = DeserializeOrThrow(resp, $"get DCV token {orderNumber}/{domainName}"); + + if (result.Meta != null && !result.Meta.IsSuccess) + { + LogApiFailure( + $"{Constants.Api.GetDcvPath} {orderNumber}/{domainName}", + resp, result.Meta.ErrorCode, result.Meta.ErrorMessage); + throw new Exception( + $"CERTInext GetDcv failed for order '{orderNumber}' domain '{domainName}': {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}."); + } + + // SOX CC7.3: log token presence (never value) so each DCV step is independently + // auditable — an auditor must be able to confirm the token was obtained before + // StageValidation was called. + Logger.LogInformation( + "GetDcv response received. OrderNumber={OrderNumber}, Domain={Domain}, TokenPresent={TokenPresent}", + orderNumber, domainName, !string.IsNullOrWhiteSpace(result.DcvDetails?.Token)); + + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task VerifyDcvAsync( + string orderNumber, + string domainName, + string dcvMethod, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + + var body = new VerifyDcvRequest + { + Meta = await BuildMetaAsync(ct), + DcvDetails = new DcvRequestDetails + { + RequestorEmail = _config.RequestorEmail, + OrderNumber = orderNumber, + DomainName = domainName, + DcvMethod = dcvMethod + } + }; + + var req = new RestRequest(Constants.Api.VerifyDcvPath, Method.Post); + req.AddJsonBody(JsonSerializer.Serialize(body, GetJsonOptions())); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var resp = await ExecuteWithRetryAsync(req, ct); + sw.Stop(); + + Logger.LogInformation( + "CERTInext API call: Method=POST, Path={Path}, OrderNumber={OrderNumber}, Domain={Domain}, HttpStatus={Status}, LatencyMs={Latency}", + Constants.Api.VerifyDcvPath, orderNumber, domainName, (int)resp.StatusCode, sw.ElapsedMilliseconds); + + if (resp.StatusCode == HttpStatusCode.Unauthorized || resp.StatusCode == HttpStatusCode.Forbidden) + { + Logger.LogError( + "VerifyDcv API authentication failure. OrderNumber={OrderNumber}, Domain={Domain}, HttpStatus={Status}", + orderNumber, domainName, (int)resp.StatusCode); + throw new Exception( + $"Authentication failure calling VerifyDcv for order '{orderNumber}' domain '{domainName}'. HTTP {(int)resp.StatusCode}. See gateway logs for details."); + } + + if (!resp.IsSuccessful) + throw new Exception( + $"CERTInext VerifyDcv failed for order '{orderNumber}' domain '{domainName}'. HTTP {(int)resp.StatusCode}. See gateway logs for details."); + + // Attempt to read meta.status from the response body + if (!string.IsNullOrWhiteSpace(resp.Content)) + { + try + { + var verifyResp = JsonSerializer.Deserialize(resp.Content, GetJsonOptions()); + if (verifyResp?.Meta != null && !verifyResp.Meta.IsSuccess) + { + // SOX CC7.3 + issue #8: log the failure with the raw body so an + // auditor / operator can see exactly what CERTInext returned. + LogApiFailure( + $"{Constants.Api.VerifyDcvPath} {orderNumber}/{domainName}", + resp, verifyResp.Meta.ErrorCode, verifyResp.Meta.ErrorMessage); + throw new Exception( + $"CERTInext VerifyDcv returned failure for order '{orderNumber}' domain '{domainName}': {verifyResp.Meta.ErrorMessage ?? verifyResp.Meta.ErrorCode}."); + } + } + catch (JsonException) { /* non-JSON 200 body is acceptable */ } + } + + // SOX CC7.3 / SOC2 CC7.3: log success only after the meta check so the log entry + // unambiguously reflects that CERTInext acknowledged the verification request. + Logger.LogInformation( + "DCV verification succeeded. OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, domainName); + Logger.MethodExit(LogLevel.Trace); + } + // --------------------------------------------------------------------------- // Auth helpers // --------------------------------------------------------------------------- @@ -872,8 +1085,10 @@ private Task BuildMetaAsync(CancellationToken ct) authKey = ComputeAuthKey(_config.ApiKey, ts, txn); } - // SOX CC6.1: log credential use (presence only, never the value) at Information. - Logger.LogInformation( + // SOC2 CC7.2: log credential use at Debug only — this is called on every outbound + // request, so Information would flood the log and degrade anomaly detection signal. + // Per-operation audit entries (LogInformation) are emitted at the call sites above. + Logger.LogDebug( "Outbound API request authenticated. AuthMode={AuthMode}, AccountNumber={AccountNumber}, " + "ApiKeyPresent={Present}", _config.AuthMode, _config.AccountNumber, !string.IsNullOrEmpty(_config.ApiKey)); @@ -890,21 +1105,38 @@ private Task BuildMetaAsync(CancellationToken ct) /// /// Computes the CERTInext authKey: SHA256(accessKey + ts + txn) as lowercase hex. + /// + /// Implemented with BouncyCastle (per the project's crypto policy: all hashing and + /// key handling goes through BouncyCastle, never BCL System.Security.Cryptography). /// private static string ComputeAuthKey(string accessKey, string ts, string txn) { string input = accessKey + ts + txn; - byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + byte[] inputBytes = Encoding.UTF8.GetBytes(input); + var digest = new Org.BouncyCastle.Crypto.Digests.Sha256Digest(); + digest.BlockUpdate(inputBytes, 0, inputBytes.Length); + byte[] hash = new byte[digest.GetDigestSize()]; + digest.DoFinal(hash, 0); return Convert.ToHexString(hash).ToLowerInvariant(); } /// - /// Generates a unique transaction ID (alphanumeric, 16–18 digits). + /// Generates a unique transaction ID (decimal, up to 18 digits). + /// + /// `txn` is part of the SHA-256 input for the CERTInext authKey + /// (SHA256(accessKey + ts + txn)). A predictable txn shrinks the search + /// space against a leaked accessKey, so we use a cryptographically-strong source + /// rather than — per the project's BouncyCastle-only + /// crypto policy, that source is Org.BouncyCastle.Security.SecureRandom. /// + private static readonly Org.BouncyCastle.Security.SecureRandom _txnRandom = + new Org.BouncyCastle.Security.SecureRandom(); + private static string GenerateTxnId() { - // Match the Postman pre-request script: Math.floor(Math.random() * 1e18 + 1) - long val = (long)(Random.Shared.NextDouble() * 1_000_000_000_000_000_000L) + 1L; + // Produce a positive long in [1, 1e18). NextLong() returns the full Int64 + // range including negatives — mask off the sign bit and reduce. + long val = (_txnRandom.NextLong() & long.MaxValue) % 1_000_000_000_000_000_000L + 1L; return val.ToString(); } @@ -936,6 +1168,11 @@ private async Task GetOrRefreshTokenAsync(CancellationToken ct) var tokenResp = await tokenClient.ExecuteAsync(tokenReq, ct); if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content)) { + // SOX CC6.1 (credential confidentiality): NEVER log tokenResp.Content, + // tokenResp.ErrorMessage, or tokenResp.ErrorException — RestSharp's + // failure paths can echo the original request including the + // `client_secret` form value. Only StatusCode + non-secret config + // identifiers are safe to log here. Logger.LogError( "OAuth2 token acquisition failed. TokenUrl={TokenUrl}, ClientId={ClientId}, HttpStatus={Status}", _config.OAuthTokenUrl, _config.OAuthClientId, (int)tokenResp.StatusCode); @@ -1053,42 +1290,100 @@ private static LegacyGetCertificateResponse MapOrderReportEntryToLegacy(OrderRep { // Note: GetOrderReport does not return requestor name/email in the ordersArray. // Those fields are only available via TrackOrder on individual orders. + System.DateTime? orderDate = null; + if (!string.IsNullOrWhiteSpace(entry.OrderDate) + && System.DateTime.TryParse(entry.OrderDate, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, + out var parsed)) + { + orderDate = parsed; + } + return new LegacyGetCertificateResponse { Id = string.IsNullOrWhiteSpace(entry.OrderNumber) ? entry.RequestNumber : entry.OrderNumber, Status = MapCertStatusIdToLegacyString(entry.CertificateStatusId), Subject = entry.DomainName, - ProfileId = entry.ProductCode + ProfileId = entry.ProductCode, + OrderDate = orderDate }; } 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); + + 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; + return new GenerateOrderSslRequest { // Meta will be set by PlaceOrderAsync OrderDetails = new SslOrderDetails { ProductCode = request.ProfileId ?? _config.DefaultProductCode ?? string.Empty, + AccountingModel = string.IsNullOrWhiteSpace(_config.AccountingModel) ? "2" : _config.AccountingModel, SaveAndHold = "0", + EmailNotifications = string.IsNullOrWhiteSpace(_config.EmailNotifications) ? "0" : _config.EmailNotifications, + + // delegationInformation — routes the order to the configured account group. + // Omitted entirely when GroupNumber is blank (the model JsonIgnore-WhenNull + // handles property absence further down). + DelegationInformation = !string.IsNullOrWhiteSpace(_config.GroupNumber) + ? new DelegationInformation { GroupNumber = _config.GroupNumber } + : null, + + // organizationDetails — declares pre-vetted org when configured. This is the + // single biggest factor in how quickly CERTInext releases an order from + // Pending System RA. When OrganizationNumber is blank we omit the whole + // block (the model is JsonIgnore-WhenNull) so the order falls back to the + // unvetted path — same behavior as the prior plugin builds. + OrganizationDetails = !string.IsNullOrWhiteSpace(_config.OrganizationNumber) + ? new OrganizationDetails + { + PreVetting = "1", + OrganizationNumber = _config.OrganizationNumber + } + : null, + RequestorInformation = new RequestorInformation { - RequestorName = request.RequesterName ?? _config.RequestorName ?? "Keyfactor Gateway", - RequestorEmail = request.RequesterEmail ?? _config.RequestorEmail ?? string.Empty, - RequestorIsdCode = _config.RequestorIsdCode ?? "1", - RequestorMobileNumber = _config.RequestorMobileNumber ?? string.Empty + RequestorName = requestorName, + RequestorEmail = requestorEmail, + RequestorIsdCode = requestorIsd, + RequestorMobileNumber = requestorMobile }, SubscriptionDetails = new SubscriptionDetails { - Validity = request.ValidityDays.HasValue - ? Math.Ceiling(request.ValidityDays.Value / 365.0).ToString("0") - : "1" + Validity = validityYears, + AutoRenew = string.IsNullOrWhiteSpace(_config.SubscriptionAutoRenew) ? "0" : _config.SubscriptionAutoRenew, + RenewCriteria = string.IsNullOrWhiteSpace(_config.SubscriptionRenewCriteriaDays) ? "30" : _config.SubscriptionRenewCriteriaDays }, CertificateInformation = new CertificateInformation { DomainName = ExtractCnFromSubject(request.Subject) ?? "unknown", - AdditionalDomains = BuildAdditionalDomains(request.Sans) + AdditionalDomains = BuildAdditionalDomains(request.Sans), + AutoSecureWww = string.IsNullOrWhiteSpace(_config.AutoSecureWww) ? "0" : _config.AutoSecureWww + }, + + // technicalPointOfContact — each field falls back to the requestor default + // when its TechnicalContact* counterpart is blank. + TechnicalPointOfContact = new TechnicalPointOfContact + { + TpcName = string.IsNullOrWhiteSpace(_config.TechnicalContactName) ? requestorName : _config.TechnicalContactName, + TpcEmail = string.IsNullOrWhiteSpace(_config.TechnicalContactEmail) ? requestorEmail : _config.TechnicalContactEmail, + TpcIsdCode = string.IsNullOrWhiteSpace(_config.TechnicalContactIsdCode) ? requestorIsd : _config.TechnicalContactIsdCode, + TpcMobileNumber = string.IsNullOrWhiteSpace(_config.TechnicalContactMobileNumber) ? requestorMobile : _config.TechnicalContactMobileNumber }, + Csr = request.Csr, AgreementDetails = BuildDefaultAgreementDetails(), AdditionalInformation = new AdditionalInformation @@ -1101,12 +1396,27 @@ private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollC private AgreementDetails BuildDefaultAgreementDetails() { + // SOC1 accuracy-of-processing: the subscriber agreement is a legal artefact + // and the SignerIp it carries is part of the audit record CERTInext stores. + // Submitting 127.0.0.1 is a misrepresentation. We retain the fallback so we + // don't break existing deployments (and our enrollment never fails just + // because SignerIp is blank), but a missing value emits a Warning so an + // auditor sees the misrepresentation as an actionable signal in the gateway log. + string signerIp = _config.SignerIp; + if (string.IsNullOrWhiteSpace(signerIp)) + { + Logger.LogWarning( + "Connector config SignerIp is empty — falling back to 127.0.0.1 for the " + + "subscriber agreement. Set the SignerIp config field to the gateway host's " + + "actual public-routable IP so the audit record is accurate."); + signerIp = "127.0.0.1"; + } return new AgreementDetails { AcceptAgreement = "1", SignerName = _config.RequestorName ?? "Keyfactor Gateway", SignerPlace = _config.SignerPlace ?? "Gateway", - SignerIp = _config.SignerIp ?? "127.0.0.1" + SignerIp = signerIp }; } @@ -1171,11 +1481,202 @@ private static T DeserializeOrThrow(RestResponse resp, string operation) wher return result; } + // SOC2 CC7.2 DoS guard: cap the size of any response body we parse here. CERTInext + // error envelopes are always under a few KB; a multi-MB body is either a misrouted + // response or a hostile payload aimed at exhausting our JsonDocument buffer. + private const int MaxErrorBodyBytes = 64 * 1024; + + // --------------------------------------------------------------------------- + // Rate-limit retry — see GitHub issue #8. + // + // The CERTInext sandbox returns the generic string "Inactive Account User." for + // several distinct conditions including burst-rate-limit rejection. Empirically + // this resolves within seconds — auto-retrying lets a transient burst limit hit + // transparently while still surfacing the original exception text for genuinely + // inactive accounts (after RateLimitMaxAttempts the throw is unchanged). + // --------------------------------------------------------------------------- + + private const int RateLimitMaxAttempts = 5; + private const double RateLimitBaseBackoffSeconds = 1.0; + + /// + /// True when matches the documented rate-limit + /// surface CERTInext uses on its sandbox. Substring + case-insensitive match; + /// the trailing punctuation/whitespace varies across observed payloads. + /// + /// + /// Contract: callers MUST only invoke this inside the + /// !result.Meta.IsSuccess branch of an API response. CERTInext's + /// successful responses are not currently observed to include this phrase, + /// but the predicate is intentionally permissive to handle CA-side wording + /// drift, and we want the safety net of the surrounding failure context. + /// + /// + /// + /// Known cost: a genuinely-inactive account (admin disabled, billing + /// hold) returns the same error string as a rate-limit hit. Today there is + /// no distinguishing errorCode field in the observed payloads, so + /// callers gated by this predicate will exhaust their full retry budget + /// (5 attempts × ~31 s total wait) before propagating the original failure + /// to the gateway. Quota cost: up to 5 enrollment attempts per affected + /// call. See GitHub issue #8 for the discussion. + /// + /// + internal static bool IsRateLimitSurface(string errorMessage) + { + if (string.IsNullOrEmpty(errorMessage)) return false; + return errorMessage.IndexOf("Inactive Account User", StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// Exponential backoff with ±25% jitter for the rate-limit retry inside + /// . Attempts 1..5 produce roughly + /// 1s / 2s / 4s / 8s / 16s of nominal delay. + /// + /// + /// Thundering-herd assumption: jitter is sampled from a process-wide + /// (_txnRandom), + /// so concurrent callers in the same process get independent samples. + /// Multiple gateway pods hitting the same CERTInext tenant each have their + /// own seeded instance, so jitter is also independent across pods. The + /// ±25% spread on the 16s nominal at attempt 5 produces a 4s window — wide + /// enough to de-correlate from the documented "~16 orders / 10 s" sandbox + /// limit if a multi-pod fleet hits the limit simultaneously. + /// + /// + /// Exposed internal so unit tests can verify the schedule. + /// + internal static double ComputeRateLimitBackoffSeconds(int attempt) + { + if (attempt < 1) attempt = 1; + double nominal = RateLimitBaseBackoffSeconds * Math.Pow(2, attempt - 1); + // ±25% jitter via SecureRandom — non-cryptographic randomness is fine for + // jitter, but we already have a SecureRandom instance for txn IDs and + // reusing it is one fewer source of randomness to think about. + double jitterFactor = 0.75 + _txnRandom.NextDouble() * 0.5; + return nominal * jitterFactor; + } + + // Cap on the response body length we include in operator-facing warning logs. + // 4 KB is comfortably more than every observed CERTInext error envelope (typically + // <500 B) while still bounding the log line if a misrouted response ever shows up. + // See GitHub issue #8 — operators need the raw body to disambiguate misleading + // CA error strings (e.g. the sandbox's "Inactive Account User." rate-limit surface). + private const int LoggedResponseBodyCapBytes = 4 * 1024; + + /// + /// Truncates to at most characters, + /// appending a "(truncated, N more chars)" marker so log readers can tell at a + /// glance that the value was cut. Returns the input unchanged when short enough. + /// + private static string Truncate(string s, int max) + { + if (string.IsNullOrEmpty(s) || s.Length <= max) return s; + return s.Substring(0, max) + $"…(truncated, {s.Length - max} more chars)"; + } + + /// + /// Scrubs known credential-bearing keys out of a JSON-ish body before it goes + /// into a log line. CERTInext error envelopes are not currently observed to + /// echo request fields, but the response shape isn't contractually fixed and + /// the authKey digest in the request meta block IS a replayable + /// privileged credential under SOX (anyone with one valid + /// (ts, txn, authKey) triple can replay until the timestamp window expires). + /// Defense-in-depth: redact before logging, not after a leak. + /// + /// Conservative substring/regex pass — handles JSON, form-urlencoded, and + /// header-line shapes. Exposed internal for unit-testing. + /// + internal static string RedactCredentials(string body) + { + if (string.IsNullOrEmpty(body)) return body; + + // JSON: "authKey": "..." → "authKey":"***REDACTED***" + // JSON: "client_secret":"..." → same + // JSON: "ApiKey":"..." → same (defensive — not currently sent on the wire, + // but the field name is a common one and the cost of redacting it is zero). + body = System.Text.RegularExpressions.Regex.Replace( + body, + @"(?i)""(authKey|client_secret|apiKey|accessKey|password)""\s*:\s*""[^""]*""", + @"""$1"":""***REDACTED***"""); + + // Form-urlencoded: client_secret=... or authKey=... (before any & or end) + body = System.Text.RegularExpressions.Regex.Replace( + body, + @"(?i)\b(authKey|client_secret|apiKey|accessKey|password)=([^&\s""]+)", + "$1=***REDACTED***"); + + // Authorization header lines if a header dump ever ends up in body shape. + // Match through end-of-line so multi-token values (e.g. "Bearer ") + // are fully scrubbed, not just the scheme word. + body = System.Text.RegularExpressions.Regex.Replace( + body, + @"(?im)^Authorization:[^\r\n]*", + "Authorization: ***REDACTED***"); + + return body; + } + + /// + /// Writes a structured log capturing every diagnostic field available for a + /// non-success CERTInext API response — HTTP status, the CERTInext-side error + /// code and message, and the (truncated, credential-scrubbed) raw response body. + /// Call this immediately before throwing so the exception's "See gateway logs + /// for details" instruction actually points somewhere useful. + /// + /// Background: issue #8 surfaced that the sandbox returns the generic string + /// "Inactive Account User." for several conditions including burst + /// rate-limit rejection. Without the raw body in the log, an operator has no + /// way to disambiguate "the account is genuinely inactive" from "you submitted + /// 16 orders in 10 seconds and the CA's burst quota kicked in." + /// + /// + /// Do NOT call this helper from the OAuth token-exchange path — that + /// request body contains the plaintext client_secret, and while + /// scrubs known credential keys defensively, + /// the token-exchange path has its own explicit log-suppression comment at + /// the existing throw site and we want to keep that path's blast radius tight. + /// + /// + /// Default is — meta-failure-on-HTTP-200 + /// is the CA saying "no" to a request, a business outcome rather than a plugin + /// fault. Callers handling authentication failures should pass + /// so SOX-loggable authentication events match + /// the SIEM-alert level convention. + /// + private static void LogApiFailure( + string operationContext, + RestResponse resp, + string errorCode = null, + string errorMessage = null, + LogLevel level = LogLevel.Warning) + { + string sanitizedBody = RedactCredentials(resp?.Content) ?? "(empty)"; + Logger.Log( + level, + "CERTInext API non-success. Operation={Operation}, HttpStatus={HttpStatus}, " + + "ErrorCode={ErrorCode}, ErrorMessage={ErrorMessage}, ResponseBody={ResponseBody}", + operationContext, + (int?)resp?.StatusCode ?? 0, + errorCode ?? "(none)", + errorMessage ?? "(none)", + Truncate(sanitizedBody, LoggedResponseBodyCapBytes)); + } + private static string ExtractErrorMessage(string content, string operation) { if (string.IsNullOrWhiteSpace(content)) return $"CERTInext returned no body for operation '{operation}'."; + if (content.Length > MaxErrorBodyBytes) + { + Logger.LogWarning( + "CERTInext response body for '{Operation}' exceeded the parser size cap " + + "({Length} bytes, cap {Cap}). Truncating before JSON parse to avoid memory exhaustion.", + operation, content.Length, MaxErrorBodyBytes); + content = content.Substring(0, MaxErrorBodyBytes); + } + try { // Try to parse as a CERTInext response with a meta block diff --git a/CERTInext/Client/ICERTInextClient.cs b/CERTInext/Client/ICERTInextClient.cs index b256ffa..cbba099 100644 --- a/CERTInext/Client/ICERTInextClient.cs +++ b/CERTInext/Client/ICERTInextClient.cs @@ -148,5 +148,29 @@ IAsyncEnumerable ListCertificatesAsync( /// Use for new code. /// Task> GetProfilesAsync(CancellationToken ct = default); + + // ----------------------------------------------------------------------- + // DCV — domain control validation endpoints (used for DV/OV SSL orders) + // ----------------------------------------------------------------------- + + /// + /// Fetches the DCV token for a single domain on an existing order via POST {baseURL}GetDcv. + /// The token is the TXT record value to publish (for dcvMethod=1 / DNS TXT). + /// + Task GetDcvAsync( + string orderNumber, + string domainName, + string dcvMethod, + CancellationToken ct = default); + + /// + /// Instructs CERTInext to verify the DCV token for a domain via POST {baseURL}VerifyDcv. + /// Call after the DNS TXT record has been published and propagated. + /// + Task VerifyDcvAsync( + string orderNumber, + string domainName, + string dcvMethod, + CancellationToken ct = default); } } diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index 65005a5..83e6929 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -16,6 +16,7 @@ public static class Config public const string ApiKey = "ApiKey"; // the raw Access Key (used to compute authKey) public const string AccountNumber = "AccountNumber"; // CERTInext account number public const string GroupNumber = "GroupNumber"; // optional delegation group number + public const string OrganizationNumber = "OrganizationNumber"; // pre-vetted organization (declares preVetting=1) public const string AuthMode = "AuthMode"; public const string Enabled = "Enabled"; public const string IgnoreExpired = "IgnoreExpired"; @@ -27,6 +28,53 @@ public static class Config public const string SignerPlace = "SignerPlace"; public const string SignerIp = "SignerIp"; + // Technical point-of-contact defaults (TpcName/Email default to Requestor* when blank) + public const string TechnicalContactName = "TechnicalContactName"; + public const string TechnicalContactEmail = "TechnicalContactEmail"; + public const string TechnicalContactIsdCode = "TechnicalContactIsdCode"; + public const string TechnicalContactMobileNumber = "TechnicalContactMobileNumber"; + + // SSL order body defaults — every value matches a CERTInext-documented field and + // is overridable by the connector admin via the gateway's connector-config UI. + public const string AccountingModel = "AccountingModel"; + public const string EmailNotifications = "EmailNotifications"; + public const string SubscriptionValidityYears = "SubscriptionValidityYears"; + public const string SubscriptionAutoRenew = "SubscriptionAutoRenew"; + public const string SubscriptionRenewCriteriaDays = "SubscriptionRenewCriteriaDays"; + public const string AutoSecureWww = "AutoSecureWww"; + + // DCV — domain control validation via DNS provider plugins + public const string DcvEnabled = "DcvEnabled"; + public const string DcvTxtRecordTemplate = "DcvTxtRecordTemplate"; + public const string DcvPropagationDelaySeconds = "DcvPropagationDelaySeconds"; + public const string DcvTimeoutMinutes = "DcvTimeoutMinutes"; + + // How long to wait inside Enroll() for CERTInext to expose the DCV challenge + // (domainVerification metadata in TrackOrder). Under concurrent load CERTInext + // sometimes takes a few seconds after GenerateOrderSSL before the slot appears. + // Without this wait, the plugin's single TrackOrder check sees null and skips + // DCV; the order then has to wait for the next gateway sync cycle to be picked up. + public const string DcvWaitForChallengeSeconds = "DcvWaitForChallengeSeconds"; + + // How long to wait inside Enroll() for CERTInext to finish generating the cert + // after DCV verification succeeds. CERTInext's issuance is async — DCV may be + // verified but the cert PEM isn't yet available for download. Without this + // wait, Enroll() returns pending and the cert is picked up on the next sync. + 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 + // 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"; + + // 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"; + public const string DcvWaitForIssuanceSecondsEnvVar = "CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS"; + // Auth mode values public const string AuthModeAccessKey = "AccessKey"; // default; authKey = SHA256(accessKey+ts+txn) public const string AuthModeOAuth = "OAuth"; // bearer token via OAuth @@ -220,6 +268,35 @@ public static class RevocationReasonId public const int Default = KeyCompromise; } + public static class Dcv + { + // CERTInext dcvMethod values (dcvDetails.dcvMethod in GetDcv / VerifyDcv) + public const string MethodDnsTxt = "1"; // DNS TXT record (numeric, used in API requests) + public const string MethodDnsTxtLabel = "DNS TXT Record"; // DNS TXT record (string label returned by TrackOrder) + public const string MethodHttpFile = "2"; // HTTP file validation + public const string MethodEmail = "3"; // Email validation + + // CERTInext dcvStatus values (per-domain entries in TrackOrder domainVerification) + public const string StatusPending = "0"; + public const string StatusValidated = "1"; + public const string StatusRejected = "2"; + + // Default TXT record hostname template; {0} is replaced with the bare domain name. + // Override via the DcvTxtRecordTemplate connector config field. + public const string DefaultTxtRecordTemplate = "_emsign-validation.{0}"; + + // Defaults for the DCV-during-sync bounds (issue 0002). + public const int DefaultSyncMaxOrderAgeHours = 24; + public const int DefaultSyncMaxPerPass = 50; + + // Propagation delay used on the *sync* DCV path (issue 0002). Sync runs frequently + // and bounds work per pass, so it uses a short delay rather than the full + // DcvPropagationDelaySeconds (which the Enroll path uses for a one-shot finish). + // A few seconds is enough for the staged TXT to be visible to CERTInext's resolver; + // if a verify lands too early, the order simply stays pending and is retried next pass. + public const int SyncPropagationDelaySeconds = 3; + } + // Legacy string revocation reasons — retained so StatusMapper still compiles. public static class RevocationReason { diff --git a/CHANGELOG.md b/CHANGELOG.md index c49165c..1fe504f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,27 @@ -# Changelog +# 1.0.0 -## [1.0.0] - Unreleased +Initial release of the CERTInext (emSign Hub) AnyCA REST Gateway plugin. -### Added +## Features +- feat(enroll): Certificate enrollment for CERTInext SSL products — DV, OV, and EV SSL, including Wildcard and Multi-Domain (UCC) variants — with connector- and template-level overrides for product code, requestor identity, organization/group, and validity. +- feat(dcv): End-to-end DNS-01 domain validation for DV SSL through a pluggable `IDomainValidatorFactory` (Cloudflare provider included). Publishes the TXT challenge, asks CERTInext to verify, waits for issuance, and returns the issued certificate directly from `Enroll`. (DCV build — AnyCA Gateway 26.x.) +- feat(sync): Full and incremental CA synchronization via paginated `GetOrderReport`. Issued certificates carry their full PEM body; revoked certificates carry revocation metadata. +- feat(sync): Sync-driven DCV retry drives orders left pending validation to completion on later sync passes, bounded by configurable `DcvSyncMaxOrderAgeHours` and `DcvSyncMaxPerPass` caps so large accounts stay fast. +- feat(revoke): Certificate revocation via `RevokeOrder` with RFC 5280 reason-code mapping. +- feat(auth): AccessKey (HMAC-SHA256) and OAuth client-credentials authentication modes. +- feat(build): Single `DcvSupport` MSBuild flag selects the host-matched build from one codebase — default no-DCV (IAnyCAPlugin `3.2.0`, AnyCA Gateway 25.5.x) or `-p:DcvSupport=true` for the DCV build (IAnyCAPlugin `3.3.0-PRERELEASE`, 26.x). Records persist only when the build matches the host's IAnyCAPlugin version. +- feat(config): Connector-level configuration for pre-vetted organization/group/technical-contact injection, DCV timing knobs (challenge/issuance waits), and SSL order defaults. +- feat(sync): `IgnoreExpired` flag to exclude expired certificates from synchronization. -- Initial release of the CERTInext AnyCA REST Gateway plugin -- Certificate enrollment for DV SSL (838), DV Wildcard (839), DV UCC (840), OV SSL (842), and EV SSL (846) product types -- Certificate revocation via `RevokeOrder` with RFC 5280 reason code mapping -- Full and incremental CA synchronization via paginated `GetOrderReport` -- AccessKey (HMAC-SHA256) and OAuth client credentials authentication modes -- `IgnoreExpired` flag to exclude expired certificates from synchronization -- Live integration tests covering all supported SSL/TLS product types (draft order mode) +## Bug Fixes +- fix(sync): Issued certificates now synchronize with their full PEM body — the `GetOrderReport` listing carries no body, so the plugin refetches the full certificate for issued/revoked records. Previously issued certs synced empty and never appeared in Command. +- fix(sync): Preserve listing metadata (`Subject`, `ProductID`, order date) when refetching the certificate body during synchronization, so issued records are not emitted with null fields. +- fix(diagnostics): Every CERTInext API failure logs the HTTP status plus the CA's error code and message; transient rate-limit responses are retried with exponential backoff and jitter. + +## Chores +- chore(crypto): All cryptographic operations (CSR/key generation, hashing, the auth nonce) use BouncyCastle exclusively — no `System.Security.Cryptography`. +- chore(deps): `BouncyCastle.Cryptography` 2.6.2 (closes 3 moderate-severity CVEs). +- chore(compat): Ship builds for both `net8.0` and `net10.0`. +- chore(logging): Verbose Debug/Trace logging across the sync flow with method entry/exit tracing. +- chore(tests): Live integration tests covering all supported SSL/TLS product types, the DCV enroll → issue → sync flow, and a key-algorithm matrix — confirms CERTInext issues RSA 2048/3072/4096 and ECC P-256/P-384, and rejects larger RSA, ECC P-521, and Ed25519/Ed448. +- chore(scripts): API smoke-test scripts for every endpoint, including `reject-order` / `reject-all-pending` for cancelling pending orders. diff --git a/Makefile b/Makefile index 1a3e8f0..c2a726f 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,13 @@ SLN := certinext-caplugin.sln 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 +# /emSignHub-API/ suffix, e.g.: +# CERTINEXT_V2_API_URL=https://sandbox-us.certinext.io +# --------------------------------------------------------------------------- + .PHONY: build test integration-test coverage coverage-report open-coverage clean \ ping \ get-product-details products \ @@ -11,10 +18,14 @@ REPORT_DIR := /tmp/certinext-coverage-report get-order-report orders \ track-order get-order \ get-certificate get-cert \ + get-dcv \ + verify-dcv \ generate-order \ revoke-order \ submit-csr \ list-cas \ + register register-profiles register-ca-config register-claims \ + register-command-ca register-import register-enrollment \ create-product \ generate-order-igtf \ generate-order-149-fresh \ @@ -24,7 +35,28 @@ REPORT_DIR := /tmp/certinext-coverage-report show-postman-bodies \ show-postman-variables \ probe-private-pki-payloads \ - api-help + api-help \ + v2-ping \ + v2-list-products \ + v2-get-custom-fields \ + v2-list-groups \ + v2-list-organizations \ + v2-list-domains \ + v2-create-ssl-order \ + v2-track-order \ + v2-get-dcv \ + v2-verify-dcv \ + v2-submit-csr \ + v2-accept-agreement \ + v2-download-certificate \ + v2-revoke-ssl \ + v2-cancel-ssl-order \ + v2-create-private-pki-order \ + v2-track-private-pki \ + v2-submit-csr-private-pki \ + v2-download-certificate-private-pki \ + v2-revoke-private-pki \ + v2-orders-report build: dotnet build $(SLN) @@ -175,6 +207,32 @@ track-order get-order: get-certificate get-cert: @ORDER_NUMBER=$(ORDER_NUMBER) scripts/get-certificate.sh + +# --------------------------------------------------------------------------- +# GetDcv — POST {baseURL}GetDcv +# Fetches the DCV token for a domain on an existing order +# Mirrors ICERTInextClient.GetDcvAsync +# Required: ORDER_NUMBER= DOMAIN_NAME= +# Optional: DCV_METHOD=1 (1=DNS TXT, 2=HTTP file, 3=Email; default 1) +# --------------------------------------------------------------------------- + +DCV_METHOD ?= 1 + +get-dcv: + @ORDER_NUMBER=$(ORDER_NUMBER) DOMAIN_NAME=$(DOMAIN_NAME) DCV_METHOD=$(DCV_METHOD) scripts/get-dcv.sh + +# --------------------------------------------------------------------------- +# VerifyDcv — POST {baseURL}VerifyDcv +# Instructs CERTInext to check the published DCV token for a domain +# Mirrors ICERTInextClient.VerifyDcvAsync +# Call after publishing the TXT record and allowing time for DNS propagation. +# Required: ORDER_NUMBER= DOMAIN_NAME= +# Optional: DCV_METHOD=1 (default 1 = DNS TXT) +# --------------------------------------------------------------------------- + +verify-dcv: + @ORDER_NUMBER=$(ORDER_NUMBER) DOMAIN_NAME=$(DOMAIN_NAME) DCV_METHOD=$(DCV_METHOD) scripts/verify-dcv.sh + # --------------------------------------------------------------------------- # GenerateOrderSSL — POST {baseURL}GenerateOrderSSL # Places a new SSL/TLS certificate order — mirrors ICERTInextClient.PlaceOrderAsync @@ -243,6 +301,51 @@ submit-csr: list-cas: @scripts/list-cas.sh +# --------------------------------------------------------------------------- +# register-* — provision profiles/templates into the AnyCA REST Gateway and +# Keyfactor Command. These talk to Command/gateway (OAuth2 client_credentials), +# NOT the CERTInext API — see scripts/lib/command-auth.sh for the env contract +# (TOKEN_URL, OIDC_CLIENT_ID/SECRET, GATEWAY_HOST, COMMAND_HOST, ...). +# +# make register # full provisioning (stages 01..06) +# make register DRY_RUN=1 # DRY_RUN forwards to every stage +# make register SKIP_03=1 # skip a stage by number +# +# Per-stage (each idempotent; add DRY_RUN=1 for an offline preview): +# make register-profiles # 01 gateway certificate profiles [CHECK=1] +# make register-ca-config # 02 gateway CAConnection + Templates +# make register-claims # 03 gateway access claims (IAM) +# make register-command-ca # 04 register CA in Command +# 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. +# 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. +# --------------------------------------------------------------------------- +register: + @scripts/register/00-register-all.sh + +register-profiles: + @scripts/register/01-gateway-profiles.sh + +register-ca-config: + @scripts/register/02-gateway-ca-config.sh + +register-claims: + @scripts/register/03-gateway-claims.sh + +register-command-ca: + @scripts/register/04-command-register-ca.sh + +register-import: + @scripts/register/05-command-import-templates.sh + +register-enrollment: + @scripts/register/06-command-enrollment-patterns.sh + # --------------------------------------------------------------------------- # create-product — Create a custom product via API # @@ -382,6 +485,269 @@ probe-private-pki-payloads: generate-test-csr --product "$(PRIVATE_PKI_CODE)" \ --save-and-hold "$(SAVE_AND_HOLD)" +# --------------------------------------------------------------------------- +# V2 API targets (credentials + CERTINEXT_V2_API_URL from ~/.env_certinext) +# +# 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. +# +# 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. +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# v2-ping — GET /api/certinext/v2/auth/me +# Connectivity + auth check; returns the account context the token resolves to. +# Mirrors ICERTInextClient.PingAsync via the V2 API. +# --------------------------------------------------------------------------- + +v2-ping: + @echo "V2 ping — GET /api/certinext/v2/auth/me" + @scripts/v2/ping.sh + +# --------------------------------------------------------------------------- +# v2-list-products — GET /api/certinext/v2/catalog/products +# Lists every SSL / Document Signer / Private PKI product the account can order. +# Each entry carries a stable productCode used as the X-Product-Code header. +# --------------------------------------------------------------------------- + +v2-list-products: + @echo "V2 list products — GET /api/certinext/v2/catalog/products" + @scripts/v2/list-products.sh + +# --------------------------------------------------------------------------- +# v2-get-custom-fields — GET /api/certinext/v2/catalog/products/{code}/custom-fields +# Returns mandatory + optional custom fields for a product code. +# Required: PRODUCT_CODE= +# --------------------------------------------------------------------------- + +V2_PRODUCT_CODE ?= 842 + +v2-get-custom-fields: + @echo "V2 get custom fields — GET /api/certinext/v2/catalog/products/$(V2_PRODUCT_CODE)/custom-fields" + @PRODUCT_CODE=$(V2_PRODUCT_CODE) scripts/v2/get-custom-fields.sh + +# --------------------------------------------------------------------------- +# v2-list-groups — GET /api/certinext/v2/groups +# Lists billing groups accessible to this account. +# Use a groupNumber in order bodies to charge a specific cost centre. +# --------------------------------------------------------------------------- + +v2-list-groups: + @echo "V2 list groups — GET /api/certinext/v2/groups" + @scripts/v2/list-groups.sh + +# --------------------------------------------------------------------------- +# v2-list-organizations — GET /api/certinext/v2/organizations +# Lists pre-vetted organizations available for OV/EV SSL orders. +# Reference an organizationNumber in order bodies to skip re-vetting. +# --------------------------------------------------------------------------- + +v2-list-organizations: + @echo "V2 list organizations — GET /api/certinext/v2/organizations" + @scripts/v2/list-organizations.sh + +# --------------------------------------------------------------------------- +# v2-list-domains — GET /api/certinext/v2/domains +# Lists domains already pre-validated under this account. +# DCV does not need to be repeated for domains in this list. +# --------------------------------------------------------------------------- + +v2-list-domains: + @echo "V2 list domains — GET /api/certinext/v2/domains" + @scripts/v2/list-domains.sh + +# --------------------------------------------------------------------------- +# v2-create-ssl-order — POST /api/certinext/v2/ssl-certificates +# Places a new SSL/TLS certificate order. +# Required: PRODUCT_CODE= DOMAIN= +# Optional: VARIANT=dv (also: ov, ev) +# +# Prints orderId on success. Use orderId with v2-track-order, v2-get-dcv, +# v2-verify-dcv, v2-submit-csr, v2-accept-agreement, v2-download-certificate, +# v2-revoke-ssl, and v2-cancel-ssl-order. +# --------------------------------------------------------------------------- + +V2_DOMAIN ?= +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 + +# --------------------------------------------------------------------------- +# v2-track-order — GET /api/certinext/v2/ssl-certificates/{orderId} +# Fetches current state of an SSL order. +# Required: ORDER_ID= +# +# Status sequence: pending-dcv -> pending-csr -> pending-agreement -> issued +# (or cancelled / revoked) +# --------------------------------------------------------------------------- + +ORDER_ID ?= + +v2-track-order: + @echo "V2 track SSL order — GET /api/certinext/v2/ssl-certificates/$(ORDER_ID)" + @ORDER_ID=$(ORDER_ID) scripts/v2/track-order.sh + +# --------------------------------------------------------------------------- +# v2-get-dcv — GET /api/certinext/v2/ssl-certificates/{orderId}/dcv?domain={domain} +# Returns DCV challenge artifacts (http-url, dns-txt, email) for a domain. +# Required: ORDER_ID= DOMAIN= +# --------------------------------------------------------------------------- + +v2-get-dcv: + @echo "V2 get DCV challenges — GET /api/certinext/v2/ssl-certificates/$(ORDER_ID)/dcv" + @ORDER_ID=$(ORDER_ID) DOMAIN=$(V2_DOMAIN) scripts/v2/get-dcv.sh + +# --------------------------------------------------------------------------- +# v2-verify-dcv — POST /api/certinext/v2/ssl-certificates/{orderId}/dcv/verify +# Asks the CA to re-check the DCV artifact you published. +# Required: ORDER_ID= DOMAIN= +# Optional: METHOD=http-url (also: dns-txt, email) +# --------------------------------------------------------------------------- + +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 + +# --------------------------------------------------------------------------- +# v2-submit-csr — PUT /api/certinext/v2/ssl-certificates/{orderId}/csr +# Attaches a PEM CSR to an SSL order. +# Required: ORDER_ID= CSR_FILE= +# --------------------------------------------------------------------------- + +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 + +# --------------------------------------------------------------------------- +# v2-accept-agreement — POST /api/certinext/v2/ssl-certificates/{orderId}/agreement +# Records Subscriber Agreement acceptance. The CA proceeds to issue after this. +# Required: ORDER_ID= +# --------------------------------------------------------------------------- + +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 + +# --------------------------------------------------------------------------- +# v2-download-certificate — GET /api/certinext/v2/ssl-certificates/{orderId}/certificate +# Downloads the issued SSL certificate (JSON with PEM, serial, subject, validity). +# Required: ORDER_ID= +# --------------------------------------------------------------------------- + +v2-download-certificate: + @echo "V2 download certificate (SSL) — GET /api/certinext/v2/ssl-certificates/$(ORDER_ID)/certificate" + @ORDER_ID=$(ORDER_ID) scripts/v2/download-certificate.sh + +# --------------------------------------------------------------------------- +# v2-revoke-ssl — POST /api/certinext/v2/ssl-certificates/{orderId}/revoke +# Permanently revokes an issued SSL certificate. +# Required: ORDER_ID= +# Optional: REASON=superseded +# +# RFC 5280 reason values: unspecified, keyCompromise, caCompromise, +# affiliationChanged, superseded, cessationOfOperation, privilegeWithdrawn +# --------------------------------------------------------------------------- + +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 + +# --------------------------------------------------------------------------- +# v2-cancel-ssl-order — POST /api/certinext/v2/ssl-certificates/{orderId}/cancel +# Withdraws an SSL order before issuance. Use v2-revoke-ssl after issuance. +# Required: ORDER_ID= +# --------------------------------------------------------------------------- + +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 + +# --------------------------------------------------------------------------- +# 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= +# +# Prints orderId on success. Use orderId with v2-track-private-pki, +# v2-submit-csr-private-pki, v2-download-certificate-private-pki, and +# v2-revoke-private-pki. +# --------------------------------------------------------------------------- + +V2_HOSTNAME ?= +V2_CA_PROFILE_ID ?= +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 + +# --------------------------------------------------------------------------- +# v2-track-private-pki — GET /api/certinext/v2/private-pki-certificates/{orderId} +# Fetches current state of a Private PKI order. +# Required: ORDER_ID= +# +# Status sequence: pending-csr -> issued (or cancelled / revoked) +# --------------------------------------------------------------------------- + +v2-track-private-pki: + @echo "V2 track Private PKI order — GET /api/certinext/v2/private-pki-certificates/$(ORDER_ID)" + @ORDER_ID=$(ORDER_ID) scripts/v2/track-private-pki.sh + +# --------------------------------------------------------------------------- +# v2-submit-csr-private-pki — PUT /api/certinext/v2/private-pki-certificates/{orderId}/csr +# Attaches a PEM CSR to a Private PKI order. The customer CA signs immediately. +# Required: ORDER_ID= CSR_FILE= +# --------------------------------------------------------------------------- + +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 + +# --------------------------------------------------------------------------- +# v2-download-certificate-private-pki — GET /api/certinext/v2/private-pki-certificates/{orderId}/certificate +# Downloads the issued Private PKI certificate. +# Required: ORDER_ID= +# --------------------------------------------------------------------------- + +v2-download-certificate-private-pki: + @echo "V2 download certificate (Private PKI) — GET /api/certinext/v2/private-pki-certificates/$(ORDER_ID)/certificate" + @ORDER_ID=$(ORDER_ID) scripts/v2/download-certificate-private-pki.sh + +# --------------------------------------------------------------------------- +# v2-revoke-private-pki — POST /api/certinext/v2/private-pki-certificates/{orderId}/revoke +# Permanently revokes an issued Private PKI certificate. +# Required: ORDER_ID= +# Optional: REASON=superseded +# +# RFC 5280 reason values: unspecified, keyCompromise, affiliationChanged, +# superseded, cessationOfOperation, privilegeWithdrawn +# --------------------------------------------------------------------------- + +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 + +# --------------------------------------------------------------------------- +# 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: + @echo "V2 orders report — GET /api/certinext/v2/reports/orders (NOTE: currently 501)" + @scripts/v2/orders-report.sh + # --------------------------------------------------------------------------- # Help # --------------------------------------------------------------------------- diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..40b8292 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,806 @@ +# CERTInext CA Plugin — Quickstart + +End-to-end setup for the **CERTInext (eMudhra) CA plugin** running behind +the Keyfactor AnyCA REST Gateway. Walks an operator from "plugin DLL is +on the gateway pod" to "Keyfactor Command can enroll an end-entity +certificate through the plugin" with copy-pasteable scripts. + +Each step is shown twice: a Bash + curl block and a PowerShell block. +Use whichever fits your shell. Variables flow forward through the doc, +so set them once and reuse them. + +--- + +## What this guide covers + +1. Authenticate to the gateway and to Command (client-credentials OAuth) +2. Create a **gateway certificate profile** for each CERTInext product + (a top-level key-algorithm policy, not tied to any CA yet) +3. Create the **gateway CA** (the plugin connection + a `Templates[]` + array that references the profiles from step 2 by name) +4. **Register the gateway CA in Command** so Command can talk to it +5. **Import templates from the gateway into Command** as + `AnyCA_` templates Command can enroll against +6. **Enroll a test certificate** end-to-end + +The CERTInext sandbox returns orders in `EXTERNAL_VALIDATION` status +(pending DCV or manual review), so the final enrollment test reports a +pending result by design — that's success. + +### Data model & dependency order + +It's easy to swap steps 2 and 3 by accident — both have things called +"templates" in them. The actual gateway data model is: + +``` +gateway certificateprofile (top-level, independent of any CA) + | + | referenced by name + v +gateway CA configuration (one record with a Templates[] array; + each entry maps ProductID -> profile) + | + | Command queries this + v +Command CA registration (/KeyfactorAPI/CertificateAuthorities) + | + | ConfigurationTenant ties to this + v +Command templates (/KeyfactorAPI/Templates/Import) +``` + +So gateway profiles **must** exist before the gateway CA config that +references them, and the gateway CA config **must** exist before +Command can register it or import templates from it. Hence steps 2 → 3 +→ 4 → 5 in that order. + +### Reference JSON for each step + +Each step that creates GET-able state has a sanitised JSON snapshot in +[`docs/reference/`](docs/reference/) from a known-working lab. Linked +again inline in each step's intro: + +| Step | Reference file | +|---|---| +| 2 — gateway profiles | [`docs/reference/gateway/certificate-profiles.json`](docs/reference/gateway/certificate-profiles.json) | +| 3 — gateway CA config | not GET-able (HTTP 405); see [`docs/reference/gateway/claims.json`](docs/reference/gateway/claims.json) for the authz table this step seeds | +| 4 — Command CA | [`docs/reference/command/certificate-authority.json`](docs/reference/command/certificate-authority.json) | +| 5 — Command templates | [`docs/reference/command/templates-certinext.json`](docs/reference/command/templates-certinext.json) | + +--- + +## Prerequisites + +| Component | Required state | +|---|---| +| Keyfactor Command | Deployed and reachable at `${COMMAND_URL}` | +| AnyCA REST Gateway | Deployed and reachable at `${GATEWAY_URL}` | +| CERTInext plugin DLL | Already staged at `/app/Extensions/certinext-caplugin/` on the gateway pod; gateway has been restarted since | +| Identity Provider | OIDC client credentials issued for both the gateway and Command (Authentik, Keycloak, Entra, etc.) | +| CERTInext sandbox account | AccessKey, AccountNumber, GroupNumber, OrganizationNumber, registered requestor email | +| CERTInext sandbox PEM | The combined intermediate + root certificate for the CERTInext sandbox issuer (required for `GatewayCertificate.ImportedCertificate`) | + +If any of those aren't true, finish the prerequisite work before +returning here. See the README's **Installation** and **Configuration** +sections for the underlying setup. + +--- + +## Step 0 — Variables + +Set these once at the top of your shell; the rest of the doc reuses them. + +### Bash + +```bash +# URLs +export COMMAND_URL="https://command.example.com" +export GATEWAY_URL="https://gateway.example.com" +export TOKEN_URL="https://auth.example.com/application/o/token/" + +# OIDC client credentials +export CMD_CLIENT_ID="" +export CMD_CLIENT_SECRET="" +export GW_CLIENT_ID="" +export GW_CLIENT_SECRET="" + +# CERTInext sandbox creds +export CERTINEXT_API_URL="https://sandbox-us-api.certinext.io/emSignHub-API" +export CERTINEXT_ACCESS_KEY="" +export CERTINEXT_ACCOUNT_NUMBER="" +export CERTINEXT_GROUP_NUMBER="" +export CERTINEXT_ORG_NUMBER="" +export CERTINEXT_REQUESTOR_NAME="Your Name" +export CERTINEXT_REQUESTOR_EMAIL="you@example.com" +export CERTINEXT_SIGNER_IP="$(curl -s https://api.ipify.org)" + +# Names you'll reference in Command after setup +export CA_LOGICAL_NAME="certinext-caplugin" # also used as ConfigurationTenant +export PRODUCT_ID="DV SSL" # the first product to register +export PRODUCT_CODE="842" # sandbox DV SSL product code + +# Sandbox issuer chain file (PEM, intermediate + root concatenated) +export SANDBOX_CHAIN_PEM="${HOME}/certinext-sandbox-chain.pem" +``` + +### PowerShell + +```powershell +# URLs +$CommandUrl = "https://command.example.com" +$GatewayUrl = "https://gateway.example.com" +$TokenUrl = "https://auth.example.com/application/o/token/" + +# OIDC client credentials +$CmdClientId = "" +$CmdClientSecret = "" +$GwClientId = "" +$GwClientSecret = "" + +# CERTInext sandbox creds +$CertInextApiUrl = "https://sandbox-us-api.certinext.io/emSignHub-API" +$CertInextAccessKey = "" +$CertInextAccountNumber = "" +$CertInextGroupNumber = "" +$CertInextOrgNumber = "" +$CertInextRequestorName = "Your Name" +$CertInextRequestorEmail = "you@example.com" +$CertInextSignerIp = (Invoke-RestMethod -Uri "https://api.ipify.org").ToString() + +# Names you'll reference in Command after setup +$CaLogicalName = "certinext-caplugin" # also used as ConfigurationTenant +$ProductId = "DV SSL" # the first product to register +$ProductCode = "842" # sandbox DV SSL product code + +# Sandbox issuer chain file (PEM, intermediate + root concatenated) +$SandboxChainPem = Join-Path $HOME "certinext-sandbox-chain.pem" +``` + +> **TLS note.** Examples use `-k` (curl) / `-SkipCertificateCheck` +> (PowerShell 7+). Remove these when you're targeting a properly-trusted +> Command / Gateway in production. + +--- + +## Step 1 — Get OAuth tokens + +Both the gateway's `/AnyGatewayREST/config/*` API and Command's +`/KeyfactorAPI/*` API use OAuth2 client credentials. Mint one token for +each; they're independent. + +### Bash + +```bash +GW_TOKEN=$(curl -sk -X POST "${TOKEN_URL}" \ + -d "grant_type=client_credentials" \ + -d "client_id=${GW_CLIENT_ID}" \ + -d "client_secret=${GW_CLIENT_SECRET}" \ + -d "scope=keyfactor-anyca-gateway" \ + | jq -r '.access_token') + +CMD_TOKEN=$(curl -sk -X POST "${TOKEN_URL}" \ + -d "grant_type=client_credentials" \ + -d "client_id=${CMD_CLIENT_ID}" \ + -d "client_secret=${CMD_CLIENT_SECRET}" \ + | jq -r '.access_token') + +[ -n "${GW_TOKEN}" ] || { echo "gateway token mint failed"; exit 1; } +[ -n "${CMD_TOKEN}" ] || { echo "command token mint failed"; exit 1; } +``` + +### PowerShell + +```powershell +$GwToken = (Invoke-RestMethod -Method Post -Uri $TokenUrl -SkipCertificateCheck ` + -Body @{ + grant_type = "client_credentials" + client_id = $GwClientId + client_secret = $GwClientSecret + scope = "keyfactor-anyca-gateway" + }).access_token + +$CmdToken = (Invoke-RestMethod -Method Post -Uri $TokenUrl -SkipCertificateCheck ` + -Body @{ + grant_type = "client_credentials" + client_id = $CmdClientId + client_secret = $CmdClientSecret + }).access_token + +if (-not $GwToken) { throw "gateway token mint failed" } +if (-not $CmdToken) { throw "command token mint failed" } +``` + +--- + +## Step 2 — Create the gateway certificate profile + +> **Reference state after this step:** see +> [`docs/reference/gateway/certificate-profiles.json`](docs/reference/gateway/certificate-profiles.json) +> for the final 8-profile shape (one per sandbox product) the gateway +> returns from `GET /AnyGatewayREST/config/certificateprofile` after +> all profiles are in place. + +A **certificate profile** on the gateway is a top-level resource: a +named key-algorithm policy that's independent of any CA. CA +configurations (created in step 3) reference these profiles by name +through their `Templates[]` array, so the profile must exist first. + +The profile sets the key constraints (allowed algorithms, sizes, +curves) the gateway enforces on incoming CSRs / key generations for any +ProductID bound to it. One profile can be shared by many CA configs; +in this guide we use a 1-to-1 profile-per-ProductID convention because +the `WirePlugin` code path in `kfclab` does the same. + +Without an explicit `key_algs` block the gateway uses an empty default +that Command interprets as "no key types allowed" — PFX enrollment then +fails with `0xA0110004` ("Key type 'RSA' is unsupported or disallowed by +policy"). The body below is the canonical "permit everything we care +about" payload. + +### Bash + +```bash +KEY_ALGS='{ + "rsa": {"bit_lengths":[2048,3072,4096]}, + "ecdsa": {"curves":["1.2.840.10045.3.1.7","1.3.132.0.34","1.3.132.0.35"]}, + "ed25519": {"bit_lengths":[255]} +}' + +PROFILE_BODY=$(jq -n \ + --arg name "${PRODUCT_ID}" \ + --argjson key_algs "${KEY_ALGS}" \ + '{name: $name, key_algs: $key_algs}') + +curl -sk -X POST "${GATEWAY_URL}/AnyGatewayREST/config/certificateprofile" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "Content-Type: application/json" \ + -d "${PROFILE_BODY}" \ + -w "\nHTTP %{http_code}\n" +``` + +If the profile already exists this POST returns a 4xx; that's fine. +For idempotent updates, GET the profile, extract its `id`, then PUT: + +```bash +PROFILE_ID=$(curl -sk "${GATEWAY_URL}/AnyGatewayREST/config/certificateprofile" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + | jq -r --arg n "${PRODUCT_ID}" '.[] | select(.name == $n) | .id') + +curl -sk -X PUT "${GATEWAY_URL}/AnyGatewayREST/config/certificateprofile" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "Content-Type: application/json" \ + -d "$(echo "${PROFILE_BODY}" | jq --argjson id "${PROFILE_ID}" '. + {id: $id}')" +``` + +### PowerShell + +```powershell +$KeyAlgs = @{ + rsa = @{ bit_lengths = @(2048, 3072, 4096) } + ecdsa = @{ curves = @( + "1.2.840.10045.3.1.7", # secp256r1 (P-256) + "1.3.132.0.34", # secp384r1 (P-384) + "1.3.132.0.35" # secp521r1 (P-521) + ) } + ed25519 = @{ bit_lengths = @(255) } +} + +$ProfileBody = @{ + name = $ProductId + key_algs = $KeyAlgs +} | ConvertTo-Json -Depth 10 + +$Headers = @{ + "Authorization" = "Bearer $GwToken" + "x-keyfactor-requested-with" = "APIClient" + "Content-Type" = "application/json" +} + +try { + Invoke-RestMethod -Method Post ` + -Uri "$GatewayUrl/AnyGatewayREST/config/certificateprofile" ` + -Headers $Headers -Body $ProfileBody -SkipCertificateCheck +} catch { + # Already exists — fetch its id and PUT instead. + $existing = Invoke-RestMethod -Method Get ` + -Uri "$GatewayUrl/AnyGatewayREST/config/certificateprofile" ` + -Headers $Headers -SkipCertificateCheck + $profile = $existing | Where-Object { $_.name -eq $ProductId } | Select-Object -First 1 + if ($profile) { + $UpdateBody = @{ + id = $profile.id + name = $ProductId + key_algs = $KeyAlgs + } | ConvertTo-Json -Depth 10 + Invoke-RestMethod -Method Put ` + -Uri "$GatewayUrl/AnyGatewayREST/config/certificateprofile" ` + -Headers $Headers -Body $UpdateBody -SkipCertificateCheck + } +} +``` + +> **Note on CERTInext key algorithm restrictions:** The gateway profile's `key_algs` block defines what Command *allows* — it does not reflect what CERTInext will accept. CERTInext additionally restricts enrollments to RSA 2048/3072/4096 and ECC P-256/P-384. Orders submitted with P-521, Ed25519, Ed448, or RSA larger than 4096 bits are accepted by Command and the gateway but rejected by CERTInext with `Invalid key size`. Configure your profiles and templates to only permit the key types CERTInext supports. + +> **Doing this for all 8 non-EV sandbox products?** Wrap Steps 2 and 3 in a +> loop over the (ProductID, ProductCode) pairs. The sandbox non-EV product +> codes are 842 (DV SSL), 843 (DV Wildcard), 844 (DV UCC), 845 (DV +> Wildcard UCC), 846 (OV SSL), 847 (OV Wildcard), 848 (OV UCC), 849 +> (OV Wildcard UCC). EV SSL (850) and EV UCC (851) require additional +> `contractSignerInfo`, `certificateApproverInfo`, and org/contract fields +> beyond the base product set. + +--- + +## Step 3 — Create the gateway CA configuration + +> **Reference state after this step:** +> [`docs/reference/gateway/claims.json`](docs/reference/gateway/claims.json) +> shows the gateway authz table — the `akadmin` admin claim is added +> as part of this step on the kfclab path, so authenticated human users +> can hit the gateway UI without being denied. +> +> The CA configuration itself is **not GET-able** (the gateway returns +> HTTP 405 on `GET /config/configuration` — POST/PUT only), so there's +> no live JSON snapshot to compare against. The exact body shape this +> step submits is documented in the script blocks below. + +This is the **single biggest configuration step**. It creates the +gateway-side CA record, which has four jobs: + +- Tell the gateway how to authenticate to the CERTInext API + (`CAConnection` block) +- Give the CA a logical name and an issuer chain to present to Command + (`GatewayRegistration` block) +- Schedule sync intervals (`ServiceSettings` block) +- **Map each ProductID to the gateway certificate profile from step 2** + (`Templates[]` array — `Templates[*].CertificateProfile` must match + a profile name created in step 2) + +The CA configuration is what Command later queries (in step 4 and +step 5) to learn about this CA. Until this POST/PUT lands, the gateway +has no CA configured and Command has nothing to register or import. + +The shape uses four top-level keys: + +| Key | Purpose | +|---|---| +| `CAConnection` | The CERTInext plugin's connection config (auth + identifying numbers). All `RequestorIsdCode`, `RequestorMobileNumber`, `SignerPlace`, `Enabled` etc. live here. | +| `GatewayRegistration` | `LogicalName` (what Command will see) + `GatewayCertificate.ImportedCertificate` (PEM blob, base64-of-PEM is also accepted). | +| `ServiceSettings` | Scan intervals; tune for your environment. | +| `Templates[]` | The (ProductID → CertificateProfile) mapping. Parameters carry per-product config like `ProductCode` and `ValidityYears`. | + +`POST` creates; `PUT` updates an existing config. Most operators end up +using `PUT` after the first run. + +### Bash + +```bash +GATEWAY_CERT_PEM=$(cat "${SANDBOX_CHAIN_PEM}") + +CONFIG_BODY=$(jq -n \ + --arg api_url "${CERTINEXT_API_URL}" \ + --arg account "${CERTINEXT_ACCOUNT_NUMBER}" \ + --arg group "${CERTINEXT_GROUP_NUMBER}" \ + --arg org "${CERTINEXT_ORG_NUMBER}" \ + --arg access_key "${CERTINEXT_ACCESS_KEY}" \ + --arg req_name "${CERTINEXT_REQUESTOR_NAME}" \ + --arg req_email "${CERTINEXT_REQUESTOR_EMAIL}" \ + --arg signer_ip "${CERTINEXT_SIGNER_IP}" \ + --arg logical "${CA_LOGICAL_NAME}" \ + --arg cert "${GATEWAY_CERT_PEM}" \ + --arg product_id "${PRODUCT_ID}" \ + --arg product_code "${PRODUCT_CODE}" \ +'{ + "CAConnection": { + "ApiUrl": $api_url, + "AccountNumber": $account, + "GroupNumber": $group, + "OrganizationNumber": $org, + "AuthMode": "AccessKey", + "ApiKey": $access_key, + "RequestorName": $req_name, + "RequestorEmail": $req_email, + "RequestorIsdCode": "1", + "RequestorMobileNumber": "0000000000", + "SignerPlace": "Gateway", + "SignerIp": $signer_ip, + "Enabled": true + }, + "GatewayRegistration": { + "LogicalName": $logical, + "GatewayCertificate": { + "Source": "FileUpload", + "ImportedCertificate": $cert + } + }, + "ServiceSettings": { + "FullScan": {"Daily": {"Time": "2:00"}}, + "IncrementalScan": {"Interval": {"Minutes": 60}} + }, + "Templates": [ + { + "ProductID": $product_id, + "Parameters": {"ProductCode": $product_code, "ValidityYears": "1"}, + "CertificateProfile": $product_id + } + ] +}') + +# POST first; if "already exists", fall through to PUT. +RESP=$(curl -sk -X POST "${GATEWAY_URL}/AnyGatewayREST/config/configuration" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "Content-Type: application/json" \ + -d "${CONFIG_BODY}" -w "\nHTTP %{http_code}") +echo "${RESP}" + +if echo "${RESP}" | grep -qiE "already exists|duplicate"; then + curl -sk -X PUT "${GATEWAY_URL}/AnyGatewayREST/config/configuration" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "Content-Type: application/json" \ + -d "${CONFIG_BODY}" -w "\nHTTP %{http_code}" +fi +``` + +### PowerShell + +```powershell +$GatewayCertPem = Get-Content -Path $SandboxChainPem -Raw + +$ConfigBody = @{ + CAConnection = @{ + ApiUrl = $CertInextApiUrl + AccountNumber = $CertInextAccountNumber + GroupNumber = $CertInextGroupNumber + OrganizationNumber = $CertInextOrgNumber + AuthMode = "AccessKey" + ApiKey = $CertInextAccessKey + RequestorName = $CertInextRequestorName + RequestorEmail = $CertInextRequestorEmail + RequestorIsdCode = "1" + RequestorMobileNumber = "0000000000" + SignerPlace = "Gateway" + SignerIp = $CertInextSignerIp + Enabled = $true + } + GatewayRegistration = @{ + LogicalName = $CaLogicalName + GatewayCertificate = @{ + Source = "FileUpload" + ImportedCertificate = $GatewayCertPem + } + } + ServiceSettings = @{ + FullScan = @{ Daily = @{ Time = "2:00" } } + IncrementalScan = @{ Interval = @{ Minutes = 60 } } + } + Templates = @( + @{ + ProductID = $ProductId + Parameters = @{ ProductCode = $ProductCode; ValidityYears = "1" } + CertificateProfile = $ProductId + } + ) +} | ConvertTo-Json -Depth 10 + +$ConfigUri = "$GatewayUrl/AnyGatewayREST/config/configuration" + +try { + Invoke-RestMethod -Method Post -Uri $ConfigUri ` + -Headers $Headers -Body $ConfigBody -SkipCertificateCheck +} catch { + # Already exists — PUT update instead. + if ($_.Exception.Message -match "already exists|duplicate") { + Invoke-RestMethod -Method Put -Uri $ConfigUri ` + -Headers $Headers -Body $ConfigBody -SkipCertificateCheck + } else { + throw + } +} +``` + +After this completes, the gateway is fully wired to CERTInext. Confirm +by GETting the configuration back: + +```bash +curl -sk "${GATEWAY_URL}/AnyGatewayREST/config/configuration" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" | jq '.Templates' +``` + +You should see your `Templates[]` array with the (ProductID, +CertificateProfile) entries from above. + +--- + +## Step 4 — Register the CA in Command + +> **Reference state after this step:** see +> [`docs/reference/command/certificate-authority.json`](docs/reference/command/certificate-authority.json) +> for the full CA record Command returns from +> `GET /KeyfactorAPI/CertificateAuthorities` (filtered to the +> `LogicalName=certinext-caplugin` entry). Useful to compare against +> when debugging — every field the API populates is present, and +> `ClientSecret.SecretValue` is masked by Command on read. + +Command needs to know the gateway exists and what auth to use when +talking to it. The CA registration carries the OAuth client used for +Command-to-gateway calls (the same gateway OAuth client from Step 1) and +the `ConfigurationTenant` that ties this registration to the gateway's +plugin (the plugin name — by convention `certinext-caplugin`). + +Important fields: + +| Field | Value | Why | +|---|---|---| +| `HostName` | `${GATEWAY_URL}/AnyGatewayREST/ejbca/ejbca-rest-api` | All AnyCA REST Gateway plugins are served behind the EJBCA-compatible prefix; Command speaks EJBCA REST to the gateway. | +| `CAType` | `1` | HTTPS (AnyCA REST). `0` is DCOM (legacy Windows). | +| `ConfigurationTenant` | `certinext-caplugin` | Must match the LogicalName the plugin uses; also the value you'll pass to `/Templates/Import` in Step 5. | +| `Scope` | `keyfactor-anyca-gateway` | The OAuth scope the gateway's token introspection allows. | +| `ClientSecret` | `{"SecretValue": "..."}` | Command's `KeyfactorSecret` shape; raw strings are rejected with `"Invalid JSON schema. Expected: 'StartObject' Received: 'String'"`. | + +### Bash + +```bash +CA_BODY=$(jq -n \ + --arg logical "${CA_LOGICAL_NAME}" \ + --arg host "${GATEWAY_URL}/AnyGatewayREST/ejbca/ejbca-rest-api" \ + --arg tenant "${CA_LOGICAL_NAME}" \ + --arg token_url "${TOKEN_URL}" \ + --arg client_id "${GW_CLIENT_ID}" \ + --arg secret "${GW_CLIENT_SECRET}" \ +'{ + "LogicalName": $logical, + "HostName": $host, + "CAType": 1, + "ConfigurationTenant": $tenant, + "NewEndEntityOnRenewAndReissue": true, + "AllowOneClickRenewals": true, + "UseForEnrollment": true, + "KeyRetention": "Indefinite", + "AllowedEnrollmentTypes": 3, + "FullScan": {"Interval": {"Minutes": 720}}, + "IncrementalScan": {"Interval": {"Minutes": 5}}, + "TokenURL": $token_url, + "ClientId": $client_id, + "ClientSecret": {"SecretValue": $secret}, + "Scope": "keyfactor-anyca-gateway" +}') + +curl -sk -X POST "${COMMAND_URL}/KeyfactorAPI/CertificateAuthorities" \ + -H "Authorization: Bearer ${CMD_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Content-Type: application/json" \ + -d "${CA_BODY}" -w "\nHTTP %{http_code}\n" +``` + +### PowerShell + +```powershell +$CaBody = @{ + LogicalName = $CaLogicalName + HostName = "$GatewayUrl/AnyGatewayREST/ejbca/ejbca-rest-api" + CAType = 1 + ConfigurationTenant = $CaLogicalName + NewEndEntityOnRenewAndReissue = $true + AllowOneClickRenewals = $true + UseForEnrollment = $true + KeyRetention = "Indefinite" + AllowedEnrollmentTypes = 3 + FullScan = @{ Interval = @{ Minutes = 720 } } + IncrementalScan = @{ Interval = @{ Minutes = 5 } } + TokenURL = $TokenUrl + ClientId = $GwClientId + ClientSecret = @{ SecretValue = $GwClientSecret } + Scope = "keyfactor-anyca-gateway" +} | ConvertTo-Json -Depth 10 + +$CmdHeaders = @{ + "Authorization" = "Bearer $CmdToken" + "x-keyfactor-requested-with" = "APIClient" + "x-keyfactor-api-version" = "1" + "Content-Type" = "application/json" +} + +Invoke-RestMethod -Method Post ` + -Uri "$CommandUrl/KeyfactorAPI/CertificateAuthorities" ` + -Headers $CmdHeaders -Body $CaBody -SkipCertificateCheck +``` + +Verify the CA appears in Command: + +```bash +curl -sk "${COMMAND_URL}/KeyfactorAPI/CertificateAuthorities" \ + -H "Authorization: Bearer ${CMD_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + | jq --arg n "${CA_LOGICAL_NAME}" '.[] | select(.LogicalName == $n)' +``` + +--- + +## Step 5 — Import templates into Command + +> **Reference state after this step:** see +> [`docs/reference/command/templates-certinext.json`](docs/reference/command/templates-certinext.json) +> for the 8 templates Command creates from the 8 ProductIDs registered +> in Step 3 (filtered from `GET /KeyfactorAPI/Templates` by +> `ConfigurationTenant=certinext-caplugin`). Confirms the +> `AnyCA_` naming convention, the `ExtendedKeyUsages` set, +> the `KeyTypes` list synced from the gateway profile's `key_algs`, +> and the per-template `Id` / `Oid` shape. + +Command's `/Templates/Import` endpoint asks the registered gateway CA +for its template list and creates corresponding Command-side templates +named `AnyCA_` (e.g. `AnyCA_DV SSL`). One call covers every +template you defined under `Templates[]` in Step 3. + +### Bash + +```bash +curl -sk -X POST "${COMMAND_URL}/KeyfactorAPI/Templates/Import" \ + -H "Authorization: Bearer ${CMD_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Content-Type: application/json" \ + -d "{\"ConfigurationTenant\":\"${CA_LOGICAL_NAME}\"}" \ + -w "\nHTTP %{http_code}\n" + +# Confirm the templates landed: +curl -sk "${COMMAND_URL}/KeyfactorAPI/Templates" \ + -H "Authorization: Bearer ${CMD_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + | jq '[.[] | select(.ShortName | startswith("AnyCA_"))] | map({Id, ShortName, DisplayName})' +``` + +### PowerShell + +```powershell +$ImportBody = @{ ConfigurationTenant = $CaLogicalName } | ConvertTo-Json + +Invoke-RestMethod -Method Post ` + -Uri "$CommandUrl/KeyfactorAPI/Templates/Import" ` + -Headers $CmdHeaders -Body $ImportBody -SkipCertificateCheck + +# Confirm: +$AllTemplates = Invoke-RestMethod -Method Get ` + -Uri "$CommandUrl/KeyfactorAPI/Templates" ` + -Headers $CmdHeaders -SkipCertificateCheck + +$AllTemplates ` + | Where-Object { $_.ShortName -like "AnyCA_*" } ` + | Select-Object Id, ShortName, DisplayName +``` + +> **Re-run after gateway profile changes.** Any time you update the +> gateway's `certificateprofile` `key_algs`, re-run this `/Templates/Import` +> call — Command caches the allowed key types per-template in +> `dbo.KeyAlgorithms` and only refreshes them through this endpoint. If +> you skip the re-import, PFX enrollment continues to fail with +> `0xA0110004` despite the gateway being correct. + +--- + +## Step 6 — Verify with a test enrollment + +End-to-end check. The CERTInext sandbox returns orders in +`EXTERNAL_VALIDATION` status (DCV or manual review pending), so a +**successful** verification returns **HTTP 200 with a null +`Pkcs12Blob`** and a `RequestDisposition` of `EXTERNAL_VALIDATION` — +that's the expected outcome, not a failure. + +### Bash (PFX) + +```bash +CN="qs-test-$(date +%s).example.com" + +PFX_BODY=$(jq -n \ + --arg template "AnyCA_${PRODUCT_ID}" \ + --arg ca "${CA_LOGICAL_NAME}" \ + --arg subject "CN=${CN},O=Quickstart,C=US" \ + --arg ts "$(date -u +%FT%TZ)" \ +'{ + Template: $template, + CertificateAuthority: $ca, + Subject: $subject, + Password: "Tr@nsientP@ss1", + IncludeChain: true, + SANs: {}, + Timestamp: $ts +}') + +curl -sk -X POST "${COMMAND_URL}/KeyfactorAPI/Enrollment/PFX" \ + -H "Authorization: Bearer ${CMD_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Content-Type: application/json" \ + -d "${PFX_BODY}" | jq '{ + RequestDisposition: .CertificateInformation.RequestDisposition, + DispositionMessage: .CertificateInformation.DispositionMessage, + KeyfactorRequestId: .CertificateInformation.KeyfactorRequestId, + WorkflowReferenceId: .CertificateInformation.WorkflowReferenceId + }' +``` + +Expected output: + +```json +{ + "RequestDisposition": "EXTERNAL_VALIDATION", + "DispositionMessage": "The certificate request is being processed by the CA, and will be available at a later time.", + "KeyfactorRequestId": 1, + "WorkflowReferenceId": 1 +} +``` + +### PowerShell (PFX) + +```powershell +$Cn = "qs-test-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()).example.com" + +$PfxBody = @{ + Template = "AnyCA_$ProductId" + CertificateAuthority = $CaLogicalName + Subject = "CN=$Cn,O=Quickstart,C=US" + Password = "Tr@nsientP@ss1" + IncludeChain = $true + SANs = @{} + Timestamp = (Get-Date).ToUniversalTime().ToString("o") +} | ConvertTo-Json -Depth 10 + +$Response = Invoke-RestMethod -Method Post ` + -Uri "$CommandUrl/KeyfactorAPI/Enrollment/PFX" ` + -Headers $CmdHeaders -Body $PfxBody -SkipCertificateCheck + +[PSCustomObject]@{ + RequestDisposition = $Response.CertificateInformation.RequestDisposition + DispositionMessage = $Response.CertificateInformation.DispositionMessage + KeyfactorRequestId = $Response.CertificateInformation.KeyfactorRequestId + WorkflowReferenceId = $Response.CertificateInformation.WorkflowReferenceId +} | Format-List +``` + +You should see `RequestDisposition = EXTERNAL_VALIDATION`. The +gateway's `Certificates` table will have a new row at status `90` +(pending external validation); once CERTInext completes DCV / manual +review, the status flips to `40` (issued) and Command's next inventory +sync pulls down the actual certificate. + +--- + +## Next steps + +- **More products.** Re-run Steps 2 (one POST per product) and update + the `Templates[]` array in Step 3's PUT to include all the + (ProductID, ProductCode, CertificateProfile) tuples you want to use. + Then re-run Step 5 (`/Templates/Import`) so Command picks up the new + templates. +- **Production hardening.** Drop `-k` / `-SkipCertificateCheck`, swap + the sandbox API URL for production + (`https://api.certinext.io/emSignHub-API`), update the + `GatewayCertificate.ImportedCertificate` to the production issuer + chain, and rotate the access key. +- **CSR enrollment.** `/KeyfactorAPI/Enrollment/CSR` accepts the same + body shape but with a `CSR` field instead of `Password`/`IncludeChain`. + Useful when the requesting system already has a keypair it doesn't + want to surface to Command. +- **Sandbox quota.** The CERTInext sandbox enforces a burst rate limit + that surfaces as the misleading error string `"Inactive Account + User."`. If you're submitting many test orders in tight succession + and start seeing that error, throttle to one order every 1-2 seconds + and wait ~5-25 minutes for the cooldown. Tracking issue: + [Keyfactor/certinext-caplugin#8](https://github.com/Keyfactor/certinext-caplugin/issues/8). + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| Step 5 returns 0 templates imported | `ConfigurationTenant` doesn't match between Steps 3 and 4 | Re-check both call to make sure the LogicalName / ConfigurationTenant agree. | +| 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. | diff --git a/README.md b/README.md index b764df3..ff91ab1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

-Integration Status: prototype +Integration Status: production Release Issues GitHub Downloads (all assets, all releases) @@ -14,7 +14,7 @@ Support - + · Requirements @@ -33,7 +33,6 @@

- 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. The plugin represents a fully featured AnyCA REST plugin with the following capabilities: * CA Synchronization: @@ -41,7 +40,7 @@ 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 via the CERTInext renew API when the prior certificate is within the configured renewal window. + * Certificate renewal — submits a new `GenerateOrderSSL` order when the prior certificate is within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). * Certificate reissuance (new keys with the same or updated subject/SANs) when outside the renewal window or no prior certificate is found. * Certificate Revocation: * Request revocation of a previously issued certificate using any RFC 5280 CRL reason code. @@ -51,17 +50,17 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi ## Compatibility -The CERTInext AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. +The CERTInext AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.5.0 and later. ## Support -The CERTInext AnyCA Gateway REST plugin is open source and there is **no SLA**. Keyfactor will address issues as resources become available. Keyfactor customers may request escalation by opening up a support ticket through their Keyfactor representative. +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. > To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. ## Requirements -* Keyfactor Command 10.x or later -* AnyCA Gateway REST framework version 24.2.0 or later +* Keyfactor Command 25.5.x or later +* AnyCA Gateway REST framework version 25.5.0 or later * 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 @@ -84,16 +83,16 @@ CERTInext operates three separate environments. Use the sandbox environment for 2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [CERTInext AnyCA Gateway REST plugin](https://github.com/Keyfactor/certinext-caplugin/releases/latest) from GitHub. -3. Copy the unzipped directory (usually called `net6.0` or `net8.0`) to the Extensions directory: +3. Copy the unzipped directory (usually called `net8.0` or `net10.0`) to the Extensions directory: ```shell Depending on your AnyCA Gateway REST version, copy the unzipped directory to one of the following locations: - Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net6.0\Extensions Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions ``` - > The directory containing the CERTInext AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0`) can be named anything, as long as it is unique within the `Extensions` directory. + > The directory containing the CERTInext AnyCA Gateway REST plugin DLLs (`net8.0` or `net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. @@ -106,7 +105,7 @@ CERTInext operates three separate environments. Use the sandbox environment for * **Gateway Registration** Before enrolling certificates, the Keyfactor Command server must trust the CERTInext issuing CA chain. - + 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**. @@ -116,63 +115,81 @@ CERTInext operates three separate environments. Use the sandbox environment for 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/ — Production (Global/India): https://api.certinext.io/ - * **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 so the full product list is returned. Some sandbox accounts require this to avoid receiving an empty product list. Available in the CERTInext portal under Delegation → Groups. - * **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'. - * **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. - * **IgnoreExpired** - If true, expired certificates will be skipped during synchronization. Default: false. - * **PageSize** - Number of orders to fetch per page during synchronization. Default: 100, max: 500. - * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA connector prior to configuration information being available. + * **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'. + * **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". + * **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. + * **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. + * **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. + * **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. 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. - 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. | `842` (sandbox DV SSL, account-specific) | - | `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` | - | `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. Valid values depend on what the target product supports. If omitted, the product default is used. | `RSA2048`, `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` | +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) | +| `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` | +| `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` | 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. - * **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. - * **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. - + * **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. + * **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. + * **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. ## CERTInext API Setup @@ -225,8 +242,8 @@ The following fields are presented in the Keyfactor Command Management Portal wh | 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**. | `4461259728` | +| `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` | @@ -238,11 +255,15 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `RequestorMobileNumber` | Optional | Requestor mobile number (digits only, no country code). Included in the `requestorInformation` block. | N/A | `5551234567` | | `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. | Use the physical city where the signer is located. | `Austin` | | `SignerIp` | Required | Public IP address of the host accepting the subscriber agreement. Required by CERTInext for all orders. | Use the outbound IP of the AnyCA Gateway host, or the IP of the workstation from which the agreement was accepted. | `203.0.113.10` | -| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests. 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**. | `2171775848` | +| `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` | | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | +| `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | +| `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | +| `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | +| `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` | > 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. @@ -262,36 +283,43 @@ 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 account (`accountNumber=9374221333`) in April 2026. Your account will likely have different codes. Always call `GetProductDetails` to confirm the codes provisioned for your account. +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) -| Product | Sandbox Code (account 9374221333, April 2026) | Required fields beyond base (`domainName`, `csr`, `requestorInformation`, `subscriptionDetails`, `agreementDetails`) | -|---|---|---| -| DV (Domain Validated) | `842` | None. `domainName` is derived from the CSR CN if omitted on the template. | -| DV Wildcard | `843` | CSR CN must use wildcard format (e.g. `*.example.com`). `domainName` in the order must also use the wildcard format. | -| DV UCC (Multi-domain) | `844` | `certificateInformation.additionalDomains` — array of additional SAN values beyond the primary `domainName`. | -| DV Wildcard UCC (Multi-domain Wildcard) | `845` | Combines wildcard and multi-domain requirements. CSR CN and `domainName` must use wildcard format; `certificateInformation.additionalDomains` required. | -| OV (Organization Validated) | `846` | `organizationDetails.organizationNumber` (your CERTInext org ID); `certificateInformation.locality`, `postalCode`, and full organization address fields (`streetAddress`, `city`, `state`, `country`). | -| OV Wildcard | `847` | Same as OV (846). CSR CN and `domainName` must use wildcard format. | -| OV UCC (Multi-domain) | `848` | Same as OV (846) plus `certificateInformation.additionalDomains`. | -| OV Wildcard UCC (Multi-domain Wildcard) | `849` | Combines OV, wildcard, and multi-domain requirements. Same as OV (846) plus wildcard CN/domainName and `certificateInformation.additionalDomains`. | -| EV (Extended Validation) | `850` | 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` | Same as EV (850) plus `certificateInformation.additionalDomains`. | +**Your account may still have different codes.** Always call `GetProductDetails` against your target environment before going live. + +| Product | Sandbox Code | Production Code | Required fields beyond base (`domainName`, `csr`, `requestorInformation`, `subscriptionDetails`, `agreementDetails`) | +|---|---|---|---| +| DV (Domain Validated) | `842` | `838` | None. `domainName` is derived from the CSR CN if omitted on the template. | +| DV Wildcard | `843` | `839` | CSR CN must use wildcard format (e.g. `*.example.com`). `domainName` in the order must also use the wildcard format. | +| DV UCC (Multi-domain) | `844` | `840` | `certificateInformation.additionalDomains` — array of additional SAN values beyond the primary `domainName`. | +| DV Wildcard UCC (Multi-domain Wildcard) | `845` | `841` | Combines wildcard and multi-domain requirements. CSR CN and `domainName` must use wildcard format; `certificateInformation.additionalDomains` required. | +| OV (Organization Validated) | `846` | `842` | `organizationDetails.organizationNumber` (your CERTInext org ID); `certificateInformation.locality`, `postalCode`, and full organization address fields (`streetAddress`, `city`, `state`, `country`). | +| OV Wildcard | `847` | `843` | Same as OV. CSR CN and `domainName` must use wildcard format. | +| OV UCC (Multi-domain) | `848` | `844` | Same as OV plus `certificateInformation.additionalDomains`. | +| OV Wildcard UCC (Multi-domain Wildcard) | `849` | `845` | Combines OV, wildcard, and multi-domain requirements. Same as OV plus wildcard CN/domainName and `certificateInformation.additionalDomains`. | +| 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: 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. ### Private PKI -| Product | Example Code | Availability | -|---|---|---| -| Sandbox emSign Intranet SSL 1 year | `149` (sandbox account 9374221333, April 2026) | Requires special provisioning by eMudhra. Not available on standard production accounts. | -| emSign Intranet SSL 1 year (production) | `100` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | -| IGTF Host 1 year | `104` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | +| 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. | -> 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` on account 9374221333) also returns EMS-1162 because the product is not provisioned even though it appears in the `GetProductDetails` list. Contact eMudhra to have these products enabled on your account. +> 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. ### S/MIME and Document Signing -| Product | Product Code | Availability | +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`. + +| 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. | @@ -308,7 +336,7 @@ The product codes in this table were observed on the US sandbox account (`accoun To retrieve the full list of product codes available to your account, call the `GetProductDetails` endpoint against your target environment. The sandbox and production APIs each return their own set of codes. -> Note: SSL/TLS products (codes 838–846) are supported on standard accounts. Private PKI (100, 104), 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. +> 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. ## Architecture @@ -556,11 +584,10 @@ The table below maps each Keyfactor Command operation to the CERTInext API endpo | List available product codes | `POST GetProductDetails` | | Attach CSR to draft order | `POST SubmitCSR` | - ## License Apache License 2.0, see [LICENSE](LICENSE). ## Related Integrations -See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). \ No newline at end of file +See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). diff --git a/docs/reference/README.md b/docs/reference/README.md new file mode 100644 index 0000000..dca29a6 --- /dev/null +++ b/docs/reference/README.md @@ -0,0 +1,82 @@ +# Reference JSON — known-working lab state + +Sanitised JSON captures of a fully-configured CERTInext lab. Useful as +**wire-format reference** when you're writing or debugging +configuration scripts: every blob here is what the live gateway and +Command returned (POST/PUT bodies aren't shown — those are documented +in [`QUICKSTART.md`](../../QUICKSTART.md)). + +## Source + +Generated from the `kfclab` localhost-kind reference lab on +2026-05-22 via: + +``` +kfclab snapshot -f examples/localhost-kind/kfclab.yaml --out /tmp/snap +``` + +Then trimmed to the CERTInext-relevant subset, with sensitive fields +either already masked by the upstream API (`ClientSecret`) or omitted +entirely (no access keys, no PAM literals). + +## Layout + +``` +docs/reference/ +├── README.md (this file) +├── gateway/ +│ ├── certificate-profiles.json GET /AnyGatewayREST/config/certificateprofile +│ └── claims.json GET /AnyGatewayREST/config/claim +└── command/ + ├── certificate-authority.json GET /KeyfactorAPI/CertificateAuthorities (CERTInext record) + └── templates-certinext.json GET /KeyfactorAPI/Templates filtered by ConfigurationTenant +``` + +## `gateway/certificate-profiles.json` + +Eight profiles, one per CERTInext sandbox product. Each carries the +same `key_algs` block — the canonical "permit RSA 2048–8192 + ECDSA +P-256/384/521 + Ed25519/Ed448" policy. Match this `key_algs` shape on +new profiles to avoid Command's misleading `0xA0110004` "Key type +disallowed by policy" error. + +> **Note:** The gateway profile defines what Command permits; CERTInext itself only +> accepts RSA 2048/3072/4096 and ECC P-256/P-384. Orders using P-521, Ed25519, +> Ed448, or RSA larger than 4096 bits are accepted by Command but rejected by +> CERTInext with `Invalid key size`. + +The profiles **don't** carry CA-binding information; they're top-level +gateway resources. The CA configuration's `Templates[].CertificateProfile` +field is what binds a product to its profile by name. + +## `gateway/claims.json` + +The gateway authorisation table. Each row maps an OIDC subject (token +`sub`) to a gateway role. The lab seeds these on every +`init-gateway`: + +- Two for the gateway's own machine client (admin + user — defensive) +- One for `akadmin` (the Authentik admin's `nameClaimType=sub`) + +Production deployments add per-operator entries here. There are no +secrets in this file. + +## `command/certificate-authority.json` + +The single `LogicalName=certinext-caplugin` CA record after Command's +own redaction of the OAuth client secret (`ClientSecret.SecretValue` is +masked by Command on read). Useful as a shape reference for the +`POST /KeyfactorAPI/CertificateAuthorities` request body in +[QUICKSTART step 4](../../QUICKSTART.md#step-4--register-the-ca-in-command). +Read-only fields populated by Command (e.g. `Id`, `LastSyncTime`, +`SyncStatus`) are present but should not be set on create. + +## `command/templates-certinext.json` + +The eight Command templates created by `POST /KeyfactorAPI/Templates/Import` +(`ConfigurationTenant=certinext-caplugin`). Each is a 1-to-1 mapping +of a CERTInext sandbox product → a Command template named +`AnyCA_` and tied back to the CA by `ConfigurationTenant`. +Useful as a sanity check after running step 5 of the quickstart: the +template count and `CommonName` set should match this file (modulo +`Id` churn). diff --git a/docs/reference/command/certificate-authority.json b/docs/reference/command/certificate-authority.json new file mode 100644 index 0000000..f42dff9 --- /dev/null +++ b/docs/reference/command/certificate-authority.json @@ -0,0 +1,63 @@ +{ + "Agent": null, + "AgentName": null, + "AgentUsername": null, + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [], + "Audience": null, + "AuthCertificate": null, + "CAType": 1, + "CertificateCleanupEnabled": null, + "ClientId": "anygateway-gateway-certinext-client", + "ClientSecret": { + "Parameters": {}, + "Provider": null, + "SecretValue": "********************" + }, + "ConfigurationTenant": "certinext-caplugin", + "ConnectorPool": null, + "Delegate": false, + "DelegateEnrollment": false, + "DeleteWithArchivedKey": null, + "DenialMax": 0, + "EnforceUniqueDN": false, + "ExplicitCredentials": false, + "ExplicitPassword": null, + "ExplicitUser": null, + "FailureMax": null, + "ForestRoot": "certinext-caplugin", + "FullScan": { + "Interval": { + "Minutes": 720 + } + }, + "HostName": "https://gateway-gateway-certinext.127.0.0.1.nip.io/AnyGatewayREST/ejbca", + "Id": 4, + "IncrementalScan": { + "Interval": { + "Minutes": 5 + } + }, + "IssuanceMax": null, + "IssuanceMin": null, + "KeyRetention": 1, + "KeyRetentionDays": null, + "LastScan": "2026-05-22T19:20:01.2730000", + "LogicalName": "certinext-caplugin", + "MonitorThresholds": false, + "NewEndEntityOnRenewAndReissue": true, + "Properties": "{}", + "RFCEnforcement": false, + "Remote": false, + "Scope": "keyfactor-anyca-gateway", + "Standalone": false, + "SubscriberTerms": false, + "ThresholdCheck": null, + "TimeAfterExpiration": null, + "TimeAfterExpirationUnits": null, + "TokenURL": "https://auth.127.0.0.1.nip.io/application/o/token/", + "UseAllowedRequesters": false, + "UseCAConnector": false, + "UseForEnrollment": true +} diff --git a/docs/reference/command/templates-certinext.json b/docs/reference/command/templates-certinext.json new file mode 100644 index 0000000..948dcae --- /dev/null +++ b/docs/reference/command/templates-certinext.json @@ -0,0 +1,243 @@ +[ + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_DV SSL", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (DV SSL)", + "EnrollmentFields": [], + "ExtendedKeyUsages": [ + { + "DisplayName": "Client Authentication", + "Id": 2, + "Oid": "1.3.6.1.5.5.7.3.2" + }, + { + "DisplayName": "Secure Email", + "Id": 4, + "Oid": "1.3.6.1.5.5.7.3.4" + } + ], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 8, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.1", + "RequiresApproval": false, + "TemplateName": "AnyCA (DV SSL)", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_DV SSL Multi-Domain (UCC)", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (DV SSL Multi-Domain (UCC))", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 10, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.3", + "RequiresApproval": false, + "TemplateName": "AnyCA (DV SSL Multi-Domain (UCC))", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_DV SSL Wildcard", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (DV SSL Wildcard)", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 9, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.2", + "RequiresApproval": false, + "TemplateName": "AnyCA (DV SSL Wildcard)", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_DV SSL Wildcard Multi-Domain (UCC)", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (DV SSL Wildcard Multi-Domain (UCC))", + "EnrollmentFields": [], + "ExtendedKeyUsages": [ + { + "DisplayName": "OCSP Signing", + "Id": 9, + "Oid": "1.3.6.1.5.5.7.3.9" + } + ], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 11, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.4", + "RequiresApproval": false, + "TemplateName": "AnyCA (DV SSL Wildcard Multi-Domain (UCC))", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_OV SSL", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (OV SSL)", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 12, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.5", + "RequiresApproval": false, + "TemplateName": "AnyCA (OV SSL)", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_OV SSL Multi-Domain (UCC)", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (OV SSL Multi-Domain (UCC))", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 14, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.7", + "RequiresApproval": false, + "TemplateName": "AnyCA (OV SSL Multi-Domain (UCC))", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_OV SSL Wildcard", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (OV SSL Wildcard)", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 13, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.6", + "RequiresApproval": false, + "TemplateName": "AnyCA (OV SSL Wildcard)", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_OV SSL Wildcard Multi-Domain (UCC)", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (OV SSL Wildcard Multi-Domain (UCC))", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 15, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.8", + "RequiresApproval": false, + "TemplateName": "AnyCA (OV SSL Wildcard Multi-Domain (UCC))", + "TemplateRegexes": [], + "UseAllowedRequesters": true + } +] diff --git a/docs/reference/gateway/certificate-profiles.json b/docs/reference/gateway/certificate-profiles.json new file mode 100644 index 0000000..08dfc6a --- /dev/null +++ b/docs/reference/gateway/certificate-profiles.json @@ -0,0 +1,314 @@ +[ + { + "id": 1, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "DV SSL" + }, + { + "id": 2, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "DV SSL Wildcard" + }, + { + "id": 3, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "DV SSL Multi-Domain (UCC)" + }, + { + "id": 4, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "DV SSL Wildcard Multi-Domain (UCC)" + }, + { + "id": 5, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "OV SSL" + }, + { + "id": 6, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "OV SSL Wildcard" + }, + { + "id": 7, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "OV SSL Multi-Domain (UCC)" + }, + { + "id": 8, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "OV SSL Wildcard Multi-Domain (UCC)" + } +] \ No newline at end of file diff --git a/docs/reference/gateway/claims.json b/docs/reference/gateway/claims.json new file mode 100644 index 0000000..66af60d --- /dev/null +++ b/docs/reference/gateway/claims.json @@ -0,0 +1,26 @@ +[ + { + "description": "Authentik machine client", + "id": 1, + "provider": "Authentik", + "role": "admin", + "type": "OAuth_sub", + "value": "ak-anygateway-gateway-certinext-client_credentials" + }, + { + "description": "Authentik machine client", + "id": 2, + "provider": "Authentik", + "role": "user", + "type": "OAuth_sub", + "value": "ak-anygateway-gateway-certinext-client_credentials" + }, + { + "description": "Authentik admin user", + "id": 3, + "provider": "Authentik", + "role": "admin", + "type": "OAuth_sub", + "value": "akadmin" + } +] \ No newline at end of file diff --git a/docsource/configuration.md b/docsource/configuration.md index eedb73b..41c872e 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -7,7 +7,7 @@ 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 via the CERTInext renew API when the prior certificate is within the configured renewal window. + * Certificate renewal — submits a new `GenerateOrderSSL` order when the prior certificate is within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). * Certificate reissuance (new keys with the same or updated subject/SANs) when outside the renewal window or no prior certificate is found. * Certificate Revocation: * Request revocation of a previously issued certificate using any RFC 5280 CRL reason code. @@ -17,8 +17,8 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi ## Requirements -* Keyfactor Command 10.x or later -* AnyCA Gateway REST framework version 24.2.0 or later +* Keyfactor Command 25.5.x or later +* AnyCA Gateway REST framework version 25.5.0 or later * 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 @@ -95,8 +95,8 @@ The following fields are presented in the Keyfactor Command Management Portal wh | 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**. | `4461259728` | +| `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` | @@ -108,11 +108,15 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `RequestorMobileNumber` | Optional | Requestor mobile number (digits only, no country code). Included in the `requestorInformation` block. | N/A | `5551234567` | | `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. | Use the physical city where the signer is located. | `Austin` | | `SignerIp` | Required | Public IP address of the host accepting the subscriber agreement. Required by CERTInext for all orders. | Use the outbound IP of the AnyCA Gateway host, or the IP of the workstation from which the agreement was accepted. | `203.0.113.10` | -| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests. 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**. | `2171775848` | +| `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` | | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | +| `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | +| `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | +| `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | +| `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` | > 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,7 +130,7 @@ In the Keyfactor Command Management Portal, navigate to **Certificate Templates* | Parameter | Required / Optional | Type | Description | Example / Default | |---|---|---|---|---| -| `ProductCode` | Optional | String | Override the numeric CERTInext product code for this template. Product codes are provisioned per account by eMudhra — obtain the correct code from `GetProductDetails` for your account. Set this explicitly when targeting the sandbox environment or when the connector `DefaultProductCode` should not apply to this template. | `842` (sandbox DV SSL, account-specific) | +| `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) | | `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` | @@ -134,7 +138,7 @@ In the Keyfactor Command Management Portal, navigate to **Certificate Templates* | `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. Valid values depend on what the target product supports. If omitted, the product default is used. | `RSA2048`, `RSA4096`, `EC256`, `EC384` | +| `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` | @@ -154,36 +158,43 @@ 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 account (`accountNumber=9374221333`) in April 2026. Your account will likely have different codes. Always call `GetProductDetails` to confirm the codes provisioned for your account. +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) -| Product | Sandbox Code (account 9374221333, April 2026) | Required fields beyond base (`domainName`, `csr`, `requestorInformation`, `subscriptionDetails`, `agreementDetails`) | -|---|---|---| -| DV (Domain Validated) | `842` | None. `domainName` is derived from the CSR CN if omitted on the template. | -| DV Wildcard | `843` | CSR CN must use wildcard format (e.g. `*.example.com`). `domainName` in the order must also use the wildcard format. | -| DV UCC (Multi-domain) | `844` | `certificateInformation.additionalDomains` — array of additional SAN values beyond the primary `domainName`. | -| DV Wildcard UCC (Multi-domain Wildcard) | `845` | Combines wildcard and multi-domain requirements. CSR CN and `domainName` must use wildcard format; `certificateInformation.additionalDomains` required. | -| OV (Organization Validated) | `846` | `organizationDetails.organizationNumber` (your CERTInext org ID); `certificateInformation.locality`, `postalCode`, and full organization address fields (`streetAddress`, `city`, `state`, `country`). | -| OV Wildcard | `847` | Same as OV (846). CSR CN and `domainName` must use wildcard format. | -| OV UCC (Multi-domain) | `848` | Same as OV (846) plus `certificateInformation.additionalDomains`. | -| OV Wildcard UCC (Multi-domain Wildcard) | `849` | Combines OV, wildcard, and multi-domain requirements. Same as OV (846) plus wildcard CN/domainName and `certificateInformation.additionalDomains`. | -| EV (Extended Validation) | `850` | 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` | Same as EV (850) plus `certificateInformation.additionalDomains`. | +**Your account may still have different codes.** Always call `GetProductDetails` against your target environment before going live. + +| Product | Sandbox Code | Production Code | Required fields beyond base (`domainName`, `csr`, `requestorInformation`, `subscriptionDetails`, `agreementDetails`) | +|---|---|---|---| +| DV (Domain Validated) | `842` | `838` | None. `domainName` is derived from the CSR CN if omitted on the template. | +| DV Wildcard | `843` | `839` | CSR CN must use wildcard format (e.g. `*.example.com`). `domainName` in the order must also use the wildcard format. | +| DV UCC (Multi-domain) | `844` | `840` | `certificateInformation.additionalDomains` — array of additional SAN values beyond the primary `domainName`. | +| DV Wildcard UCC (Multi-domain Wildcard) | `845` | `841` | Combines wildcard and multi-domain requirements. CSR CN and `domainName` must use wildcard format; `certificateInformation.additionalDomains` required. | +| OV (Organization Validated) | `846` | `842` | `organizationDetails.organizationNumber` (your CERTInext org ID); `certificateInformation.locality`, `postalCode`, and full organization address fields (`streetAddress`, `city`, `state`, `country`). | +| OV Wildcard | `847` | `843` | Same as OV. CSR CN and `domainName` must use wildcard format. | +| OV UCC (Multi-domain) | `848` | `844` | Same as OV plus `certificateInformation.additionalDomains`. | +| OV Wildcard UCC (Multi-domain Wildcard) | `849` | `845` | Combines OV, wildcard, and multi-domain requirements. Same as OV plus wildcard CN/domainName and `certificateInformation.additionalDomains`. | +| 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: 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. ### Private PKI -| Product | Example Code | Availability | -|---|---|---| -| Sandbox emSign Intranet SSL 1 year | `149` (sandbox account 9374221333, April 2026) | Requires special provisioning by eMudhra. Not available on standard production accounts. | -| emSign Intranet SSL 1 year (production) | `100` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | -| IGTF Host 1 year | `104` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | +| 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. | -> 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` on account 9374221333) also returns EMS-1162 because the product is not provisioned even though it appears in the `GetProductDetails` list. Contact eMudhra to have these products enabled on your account. +> 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. ### S/MIME and Document Signing -| Product | Product Code | Availability | +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`. + +| 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. | @@ -200,7 +211,7 @@ The product codes in this table were observed on the US sandbox account (`accoun To retrieve the full list of product codes available to your account, call the `GetProductDetails` endpoint against your target environment. The sandbox and production APIs each return their own set of codes. -> Note: SSL/TLS products (codes 838–846) are supported on standard accounts. Private PKI (100, 104), 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. +> 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. ## Mechanics @@ -220,8 +231,8 @@ Where `requestTs` is the ISO 8601 timestamp and `requestTxnId` is a unique trans When the gateway calls `Enroll`, the plugin selects between three paths based on the enrollment type and the age of the prior certificate: 1. **New enrollment** — no prior certificate exists. A new `GenerateOrderSSL` request is submitted. -2. **Renewal** — a prior certificate exists and its expiry is within the `RenewalWindowDays` threshold (default: 90 days). The plugin calls the CERTInext renew API, which reuses the existing subscription term. -3. **Reissue** — a prior certificate exists but is outside the renewal window. A new order is placed with the updated CSR/subject, replacing the prior certificate under a new subscription. +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 `RenewalWindowDays` template parameter controls the renewal/reissue boundary per certificate template. diff --git a/docsource/development.md b/docsource/development.md index a01f6de..14c6cff 100644 --- a/docsource/development.md +++ b/docsource/development.md @@ -40,6 +40,24 @@ 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) | + +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. + ## 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`. @@ -62,6 +80,9 @@ make orders # lists recent orders — useful to find an ORDER_NUMBER to test | Place a draft order | `make generate-order DOMAIN=example.com [CSR_FILE=req.pem] [VALIDITY=1] [SAVE_AND_HOLD=1]` | `GenerateOrderSSL` — places a new order; `SAVE_AND_HOLD=1` (default) creates a draft | | Revoke an order | `make revoke-order ORDER_NUMBER=NNNNN [REASON_ID=1]` | `RevokeOrder` — revokes an issued certificate | | Attach a CSR to a draft | `make submit-csr ORDER_NUMBER=NNNNN CSR_FILE=req.pem` | `SubmitCSR` — attaches a CSR to a saveAndHold draft order | +| 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 | > 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. diff --git a/docsource/overview.md b/docsource/overview.md new file mode 100644 index 0000000..f11d3d3 --- /dev/null +++ b/docsource/overview.md @@ -0,0 +1,92 @@ +## 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. + +## CERTInext CA Certificates + +Before the gateway can register a CA backed by this plugin, the Keyfactor Command server (and the AnyCA Gateway REST host) must trust the CERTInext issuing CA chain. Download the root and any intermediate CA certificates from the CERTInext portal for the environment you are targeting: + +| Environment | Portal Sign-in URL | +|---|---| +| Sandbox | https://sandbox-us.certinext.io/ | +| Production — India (Global) | https://in.certinext.io/ | +| Production — US | https://us.certinext.io/ | + +After signing in, navigate to the certificate-authority / chain download page in the portal, export each CA in the chain as PEM or DER, and import them into the appropriate Windows certificate stores on the gateway host (Trusted Root for the root CA, Intermediate Certification Authorities for any subordinates). See [configuration.md](configuration.md#gateway-registration) and the [README](../README.md#configuration) for the full Gateway Registration walkthrough. + +## Troubleshooting + +### `"Inactive Account User."` returned from `GenerateOrderSSL` + +**Symptom** + +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. + +**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. + +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. + +**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. + +**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. + +### 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. + +**Root cause** + +This is the expected return shape on two paths: + +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. + +**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. + +### `EMS-956 "Invalid Request for this API"` from `GetDcv` + +**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). + +**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". + +**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). + +### 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`. + +**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. + +**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. diff --git a/integration-manifest.json b/integration-manifest.json index 2056b50..8a6d4ee 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -2,12 +2,12 @@ "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", "integration_type": "anyca-plugin", "name": "CERTInext AnyCA REST Gateway Plugin", - "status": "prototype", - "support_level": "kf-community", + "status": "production", + "support_level": "kf-supported", "link_github": true, "update_catalog": true, "description": "AnyCA REST Gateway plugin for CERTInext (eMudhra) certificate lifecycle management platform", - "gateway_framework": "24.2.0", + "gateway_framework": "25.5.0", "release_dir": "CERTInext/bin/Release", "release_project": "CERTInext/CERTInext.csproj", "about": { @@ -27,7 +27,7 @@ "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/ \u2014 Production (Global/India): https://api.certinext.io/" + "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/" }, { "name": "AccountNumber", @@ -35,7 +35,27 @@ }, { "name": "GroupNumber", - "description": "OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests so the full product list is returned. Some sandbox accounts require this to avoid receiving an empty product list. Available in the CERTInext portal under Delegation \u2192 Groups." + "description": "OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests AND in the `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." + }, + { + "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." + }, + { + "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." + }, + { + "name": "TechnicalContactEmail", + "description": "OPTIONAL: Email sent in the `technicalPointOfContact.tpcEmail` field of every SSL order. Defaults to the configured RequestorEmail when blank." + }, + { + "name": "TechnicalContactIsdCode", + "description": "OPTIONAL: International dialing code for the TPoC phone number. Defaults to the configured RequestorIsdCode when blank." + }, + { + "name": "TechnicalContactMobileNumber", + "description": "OPTIONAL: Mobile number for the TPoC (digits only). Defaults to the configured RequestorMobileNumber when blank." }, { "name": "AuthMode", @@ -85,6 +105,30 @@ "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." }, + { + "name": "AccountingModel", + "description": "OPTIONAL: CERTInext billing model sent in `orderDetails.accountingModel`. \"2\" = credit-based (most accounts, default). \"1\" = cash model." + }, + { + "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\"." + }, + { + "name": "SubscriptionValidityYears", + "description": "OPTIONAL: Default validity in years for SSL orders. \"1\", \"2\", or \"3\". Override per template via the ValidityYears product parameter. Default: \"1\"." + }, + { + "name": "SubscriptionAutoRenew", + "description": "OPTIONAL: Whether CERTInext should auto-renew certificates issued through this connector. \"0\" = disabled (recommended \u2014 renewal is driven by Keyfactor Command), \"1\" = enabled. Default: \"0\"." + }, + { + "name": "SubscriptionRenewCriteriaDays", + "description": "OPTIONAL: Days before expiry at which CERTInext auto-renews (only honored when SubscriptionAutoRenew = \"1\"). Typical values: \"30\" or \"60\". Default: \"30\"." + }, + { + "name": "AutoSecureWww", + "description": "OPTIONAL: If \"1\", CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN. \"0\" = use only the CN/SANs supplied with the CSR. Default: \"0\"." + }, { "name": "IgnoreExpired", "description": "If true, expired certificates will be skipped during synchronization. Default: false." @@ -95,7 +139,39 @@ }, { "name": "Enabled", - "description": "Enables or disables the CA connector. Set to false to save the connector record before credentials are available without triggering a live connectivity test. Default: true." + "description": "Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true." + }, + { + "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." + }, + { + "name": "DcvTxtRecordTemplate", + "description": "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}" + }, + { + "name": "DcvPropagationDelaySeconds", + "description": "OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: 30." + }, + { + "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." + }, + { + "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." + }, + { + "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." + }, + { + "name": "DcvSyncMaxOrderAgeHours", + "description": "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." + }, + { + "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." } ], "enrollment_config": [ @@ -154,4 +230,4 @@ ] } } -} \ No newline at end of file +} diff --git a/scripts/get-dcv.sh b/scripts/get-dcv.sh new file mode 100755 index 0000000..8b317d9 --- /dev/null +++ b/scripts/get-dcv.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Required env vars: ORDER_NUMBER, DOMAIN_NAME +# Optional: DCV_METHOD (default: 1 = DNS TXT record) +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/lib/certinext-auth.sh" + +ORDER_NUMBER="${ORDER_NUMBER:?Usage: ORDER_NUMBER= DOMAIN_NAME= [DCV_METHOD=1] scripts/get-dcv.sh}" +DOMAIN_NAME="${DOMAIN_NAME:?DOMAIN_NAME is required}" +DCV_METHOD="${DCV_METHOD:-1}" + +read -r ts txn authKey <<< "$(certinext_meta)" + +# SOC2 CC6.1: do NOT echo authKey — it is a valid single-use request authenticator. +echo "GetDcv orderNumber=$ORDER_NUMBER domainName=$DOMAIN_NAME dcvMethod=$DCV_METHOD ts=$ts txn=$txn" + +# SOX CC6.6: use jq --arg to safely interpolate all user-supplied values into the JSON body, +# preventing shell injection via specially crafted ORDER_NUMBER or DOMAIN_NAME values. +jq -n \ + --arg ver "1.0" \ + --arg ts "$ts" \ + --arg txn "$txn" \ + --arg acct "$CERTINEXT_ACCOUNT_NUMBER" \ + --arg authKey "$authKey" \ + --arg email "$CERTINEXT_REQUESTOR_EMAIL" \ + --arg order "$ORDER_NUMBER" \ + --arg domain "$DOMAIN_NAME" \ + --arg method "$DCV_METHOD" \ + '{ + meta: {ver: $ver, ts: $ts, txn: $txn, accountNumber: $acct, authKey: $authKey}, + dcvDetails: {requestorEmail: $email, orderNumber: $order, domainName: $domain, dcvMethod: $method} + }' \ +| curl -s -X POST "$CERTINEXT_API_URL/GetDcv" \ + -H "Content-Type: application/json" \ + --data-binary @- \ +| jq . diff --git a/scripts/lib/certinext-v2-auth.sh b/scripts/lib/certinext-v2-auth.sh new file mode 100755 index 0000000..0ed2bc2 --- /dev/null +++ b/scripts/lib/certinext-v2-auth.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Shared Bearer-token helper for CERTInext V2 API scripts. +# +# Usage (from a script in scripts/v2/): +# source "$(dirname "$0")/../lib/certinext-v2-auth.sh" +# # $CERTINEXT_V2_TOKEN is now set +# +# 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). +# +# 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 + exit 1 +fi + +export CERTINEXT_V2_TOKEN + +unset _v2_ts _v2_txn _v2_authKey _v2_token_response diff --git a/scripts/lib/command-auth.sh b/scripts/lib/command-auth.sh new file mode 100755 index 0000000..d6bbecc --- /dev/null +++ b/scripts/lib/command-auth.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Shared OAuth2 + REST helpers for Keyfactor Command / AnyCA REST Gateway +# *provisioning* scripts (scripts/register/*). +# +# This is distinct from certinext-auth.sh: that helper signs CERTInext API +# requests (SHA256 authKey). This one talks to Command and the gateway admin +# API using an OAuth2 client_credentials bearer token. +# +# Usage: +# . ~/.env_certinext +# . "$(dirname "$0")/lib/command-auth.sh" +# tok=$(gateway_token) +# gw_curl "$tok" GET /config/certificateprofile +# +# Required env (set in ~/.env_certinext or exported before sourcing): +# TOKEN_URL OAuth token endpoint (Authentik), e.g. +# https://auth.127.0.0.1.nip.io/application/o/token/ +# OIDC_CLIENT_ID client_credentials client id +# OIDC_CLIENT_SECRET client_credentials client secret +# GATEWAY_HOST gateway ingress host (no scheme) +# COMMAND_HOST Command ingress host (no scheme) +# Optional env (defaults shown): +# GATEWAY_SCHEME https +# GATEWAY_BASE_PATH /AnyGatewayREST (gateway admin API prefix) +# GATEWAY_SCOPE keyfactor-anyca-gateway +# COMMAND_SCHEME https +# CURL_INSECURE 1 (pass -k; set 0 to verify TLS) +# CONFIGURATION_TENANT certinext-caplugin + +GATEWAY_SCHEME="${GATEWAY_SCHEME:-https}" +# GATEWAY_BASE_PATH is the gateway *instance* mount path, NOT a fixed value. +# On a multi-tenant AnyCA REST Gateway each instance lives under its own path +# (e.g. /certinext-0). Discover it from the Portal/Swagger URL. The historical +# default /AnyGatewayREST only applies to single-instance gateways. +GATEWAY_BASE_PATH="${GATEWAY_BASE_PATH:-/AnyGatewayREST}" +GATEWAY_SCOPE="${GATEWAY_SCOPE:-keyfactor-anyca-gateway}" +COMMAND_SCHEME="${COMMAND_SCHEME:-https}" +# Command API base path. A Portal *session cookie* (COMMAND_COOKIE) only works +# against /KeyfactorProxy — the Portal's reverse proxy that injects the bearer +# token server-side. Direct bearer/OAuth auth uses /KeyfactorAPI. When unset, +# cmd_base() resolves it at call time from whether a cookie is set (so it works +# regardless of env-var ordering). Set COMMAND_BASE_PATH to force either path. +COMMAND_BASE_PATH="${COMMAND_BASE_PATH:-}" +CONFIGURATION_TENANT="${CONFIGURATION_TENANT:-certinext-caplugin}" +CURL_INSECURE="${CURL_INSECURE:-1}" + +_ca_require() { + local missing=0 v + for v in "$@"; do + if [ -z "${!v:-}" ]; then + echo "ERROR: required env var '$v' is not set" >&2 + missing=1 + fi + done + [ "$missing" -eq 0 ] || return 1 +} + +# Base curl flags shared by every call (bash 3.2 compatible — global array). +CA_CURL_OPTS=(-sS) +[ "$CURL_INSECURE" = "1" ] && CA_CURL_OPTS+=(-k) + +# oauth_token [scope] — fetch a client_credentials bearer token. +# Echoes the raw access_token. Exits non-zero (and prints the body) on failure. +oauth_token() { + _ca_require TOKEN_URL OIDC_CLIENT_ID OIDC_CLIENT_SECRET || return 1 + local scope="${1:-}" + local -a form=( + --data-urlencode "grant_type=client_credentials" + --data-urlencode "client_id=${OIDC_CLIENT_ID}" + --data-urlencode "client_secret=${OIDC_CLIENT_SECRET}" + ) + [ -n "$scope" ] && form+=(--data-urlencode "scope=${scope}") + + local resp tok + resp=$(curl "${CA_CURL_OPTS[@]}" -X POST "$TOKEN_URL" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + "${form[@]}") || { echo "ERROR: token request failed" >&2; return 1; } + tok=$(printf '%s' "$resp" | jq -r '.access_token // empty') + if [ -z "$tok" ]; then + echo "ERROR: no access_token in response:" >&2 + printf '%s\n' "$resp" >&2 + return 1 + fi + printf '%s' "$tok" +} + +# Auth resolution order (per side): +# 1. A browser-session cookie (GATEWAY_COOKIE / COMMAND_COOKIE) — paste the +# full `cookie:` header value from devtools (Copy as cURL) when the UI uses +# OIDC session cookies instead of bearer tokens. The *_token fns return +# empty in this mode; gw_curl/cmd_curl send the Cookie header instead. +# 2. An explicit pre-obtained bearer token (GATEWAY_TOKEN / COMMAND_TOKEN). +# 3. OAuth2 client_credentials via oauth_token (needs OIDC_CLIENT_* + TOKEN_URL). +gateway_token() { + if [ -n "${GATEWAY_COOKIE:-}" ]; then return 0; fi # cookie mode + if [ -n "${GATEWAY_TOKEN:-}" ]; then printf '%s' "$GATEWAY_TOKEN"; return 0; fi + oauth_token "$GATEWAY_SCOPE" +} +command_token() { + if [ -n "${COMMAND_COOKIE:-}" ]; then return 0; fi # cookie mode + if [ -n "${COMMAND_TOKEN:-}" ]; then printf '%s' "$COMMAND_TOKEN"; return 0; fi + oauth_token "" +} + +gw_base() { + _ca_require GATEWAY_HOST || return 1 + printf '%s://%s%s' "$GATEWAY_SCHEME" "$GATEWAY_HOST" "$GATEWAY_BASE_PATH" +} +cmd_base() { + _ca_require COMMAND_HOST || return 1 + local bp="$COMMAND_BASE_PATH" + if [ -z "$bp" ]; then + if [ -n "${COMMAND_COOKIE:-}" ]; then bp="/KeyfactorProxy"; else bp="/KeyfactorAPI"; fi + fi + printf '%s://%s%s' "$COMMAND_SCHEME" "$COMMAND_HOST" "$bp" +} + +# Display helpers for log headers: the base URL, or a clear "(unset)" note. +gw_show() { if [ -n "${GATEWAY_HOST:-}" ]; then gw_base; else printf '(GATEWAY_HOST unset)'; fi; } +cmd_show() { if [ -n "${COMMAND_HOST:-}" ]; then cmd_base; else printf '(COMMAND_HOST unset)'; fi; } + +# gw_curl [data] [extra curl args...] +# Hits the gateway admin API. is relative to GATEWAY_BASE_PATH +# (e.g. /config/certificateprofile). Echoes the response body. +gw_curl() { + local tok="$1" method="$2" path="$3" data="${4:-}"; shift; shift; shift + [ $# -gt 0 ] && shift || true + # In cookie mode, mimic the browser exactly (XMLHttpRequest + CSRF header). + local rw="APIClient" + [ -n "${GATEWAY_COOKIE:-}" ] && rw="XMLHttpRequest" + local -a args=("${CA_CURL_OPTS[@]}" -X "$method" "$(gw_base)$path" + -H "x-keyfactor-requested-with: $rw" + -H "Content-Type: application/json") + if [ -n "${GATEWAY_COOKIE:-}" ]; then + args+=(-H "Cookie: ${GATEWAY_COOKIE}" -H "x-requested-with: XMLHttpRequest") + fi + [ -n "$tok" ] && args+=(-H "Authorization: Bearer $tok") + [ -n "$data" ] && args+=(-d "$data") + args+=("$@") + curl "${args[@]}" +} + +# cmd_curl [data] [api-version] [extra curl args...] +# Hits the Command KeyfactorAPI. is relative to /KeyfactorAPI. +cmd_curl() { + local tok="$1" method="$2" path="$3" data="${4:-}" ver="${5:-1}" + shift; shift; shift + [ $# -gt 0 ] && shift || true + [ $# -gt 0 ] && shift || true + local rw="APIClient" + [ -n "${COMMAND_COOKIE:-}" ] && rw="XMLHttpRequest" + local -a args=("${CA_CURL_OPTS[@]}" -X "$method" "$(cmd_base)$path" + -H "x-keyfactor-api-version: $ver" + -H "x-keyfactor-requested-with: $rw" + -H "Content-Type: application/json") + if [ -n "${COMMAND_COOKIE:-}" ]; then + args+=(-H "Cookie: ${COMMAND_COOKIE}" -H "x-requested-with: XMLHttpRequest") + fi + [ -n "$tok" ] && args+=(-H "Authorization: Bearer $tok") + [ -n "$data" ] && args+=(-d "$data") + args+=("$@") + curl "${args[@]}" +} + +# manifest_product_ids [manifest-path] — emit product_ids one per line. +manifest_product_ids() { + local manifest="${1:-$REPO_ROOT/integration-manifest.json}" + jq -r '.about.carest.product_ids[]' "$manifest" +} diff --git a/scripts/register/00-register-all.sh b/scripts/register/00-register-all.sh new file mode 100755 index 0000000..1ae3de4 --- /dev/null +++ b/scripts/register/00-register-all.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Orchestrator — run the full gateway + Command registration in order. +# +# Each stage is an independent script and can be run on its own. This driver +# runs them in sequence, skipping any stage whose script does not yet exist +# (stages 02-06 are added incrementally) or whose SKIP_ flag is set to 1. +# +# make register +# SKIP_03=1 make register # skip claims +# DRY_RUN=1 make register # forwarded to every stage +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# stage number -> script basename +STAGES=( + "01:01-gateway-profiles.sh" + "02:02-gateway-ca-config.sh" + "03:03-gateway-claims.sh" + "04:04-command-register-ca.sh" + "05:05-command-import-templates.sh" + "06:06-command-enrollment-patterns.sh" +) + +for entry in "${STAGES[@]}"; do + num="${entry%%:*}" + script="${entry#*:}" + skip_var="SKIP_${num}" + path="$SCRIPT_DIR/$script" + + if [ "${!skip_var:-0}" = "1" ]; then + echo ">> stage $num ($script): SKIPPED (${skip_var}=1)" + continue + fi + if [ ! -x "$path" ]; then + echo ">> stage $num ($script): not yet implemented — skipping" + continue + fi + + echo ">> stage $num ($script): running" + "$path" + echo +done + +echo ">> registration complete" diff --git a/scripts/register/01-gateway-profiles.sh b/scripts/register/01-gateway-profiles.sh new file mode 100755 index 0000000..1d7b242 --- /dev/null +++ b/scripts/register/01-gateway-profiles.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Stage 01 — register AnyCA REST Gateway certificate profiles. +# +# Creates (or updates) one gateway certificate profile per CERTInext product, +# driven by .about.carest.product_ids in integration-manifest.json. Idempotent: +# existing profiles are PUT-updated, new ones are POSTed. +# +# Env: see scripts/lib/command-auth.sh for the OAuth/host contract. +# Optional: +# KEY_ALGS_JSON override the key_algs object (default: lab set below) +# MANIFEST path to integration-manifest.json (default: repo root) +# CHECK 1 = after applying, diff result vs the captured reference +# (docs/reference/gateway/certificate-profiles.json) +# DRY_RUN 1 = print intended actions, make no write calls +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +MANIFEST="${MANIFEST:-$REPO_ROOT/integration-manifest.json}" +DRY_RUN="${DRY_RUN:-0}" +CHECK="${CHECK:-0}" + +# Lab default key algorithms — matches docs/reference/gateway/certificate-profiles.json. +DEFAULT_KEY_ALGS_JSON='{ + "rsa": { "bit_lengths": [2048, 3072, 4096, 6144, 8192] }, + "ecdsa": { "curves": ["1.2.840.10045.3.1.7", "1.3.132.0.34", "1.3.132.0.35"] }, + "ed25519": { "bit_lengths": [255] }, + "ed448": { "bit_lengths": [448] } +}' +KEY_ALGS_JSON="${KEY_ALGS_JSON:-$DEFAULT_KEY_ALGS_JSON}" + +if ! echo "$KEY_ALGS_JSON" | jq -e . >/dev/null 2>&1; then + echo "ERROR: KEY_ALGS_JSON is not valid JSON" >&2 + exit 1 +fi + +echo "== Stage 01: gateway certificate profiles ==" +echo " gateway : $(gw_show)" +echo " manifest: $MANIFEST" +[ "$DRY_RUN" = "1" ] && echo " DRY_RUN : no write calls will be made" + +PRODUCTS=() +while IFS= read -r _p; do + [ -n "$_p" ] && PRODUCTS+=("$_p") +done < <(manifest_product_ids "$MANIFEST") +[ "${#PRODUCTS[@]}" -gt 0 ] || { echo "ERROR: no product_ids in manifest" >&2; exit 1; } +echo " products: ${#PRODUCTS[@]}" + +if [ "$DRY_RUN" = "1" ]; then + # Fully offline preview: no token, no listing. + echo " (dry run) would upsert ${#PRODUCTS[@]} profiles with key_algs:" + echo "$KEY_ALGS_JSON" | jq -c . + for name in "${PRODUCTS[@]}"; do + printf ' [DRY ] %s\n' "$name" + done + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(gateway_token)" + +# Snapshot existing profiles once: name -> id. +EXISTING="$(gw_curl "$TOK" GET /config/certificateprofile)" +if ! echo "$EXISTING" | jq -e 'type == "array"' >/dev/null 2>&1; then + echo "ERROR: unexpected response listing certificate profiles:" >&2 + printf '%s\n' "$EXISTING" >&2 + exit 1 +fi + +created=0 updated=0 +for name in "${PRODUCTS[@]}"; do + existing_id="$(echo "$EXISTING" | jq -r --arg n "$name" \ + '.[] | select(.name == $n) | .id' | head -n1)" + + body="$(jq -n --arg name "$name" --argjson algs "$KEY_ALGS_JSON" \ + '{name: $name, key_algs: $algs}')" + + if [ -n "$existing_id" ] && [ "$existing_id" != "null" ]; then + body="$(echo "$body" | jq --argjson id "$existing_id" '. + {id: $id}')" + printf ' [PUT ] %-40s (id=%s)\n' "$name" "$existing_id" + if [ "$DRY_RUN" != "1" ]; then + resp="$(gw_curl "$TOK" PUT /config/certificateprofile "$body")" + echo "$resp" | jq -e 'has("error") or has("Message")' >/dev/null 2>&1 \ + && { echo " ! update failed: $resp" >&2; } + fi + updated=$((updated + 1)) + else + printf ' [POST] %-40s (new)\n' "$name" + if [ "$DRY_RUN" != "1" ]; then + resp="$(gw_curl "$TOK" POST /config/certificateprofile "$body")" + echo "$resp" | jq -e 'has("error") or has("Message")' >/dev/null 2>&1 \ + && { echo " ! create failed: $resp" >&2; } + fi + created=$((created + 1)) + fi +done + +echo "== done: $created created, $updated updated ==" + +if [ "$CHECK" = "1" ] && [ "$DRY_RUN" != "1" ]; then + ref="$REPO_ROOT/docs/reference/gateway/certificate-profiles.json" + echo "== CHECK: comparing live profile names vs $ref ==" + live_names="$(gw_curl "$TOK" GET /config/certificateprofile | jq -r '[.[].name] | sort')" + # Reference only captured DV/OV (no EV); compare on the set the reference covers. + ref_names="$(jq -r '[.[].name] | sort' "$ref")" + missing="$(jq -n --argjson live "$live_names" --argjson ref "$ref_names" \ + '$ref - $live')" + if [ "$(echo "$missing" | jq 'length')" -eq 0 ]; then + echo " OK: all reference profiles present on the gateway" + else + echo " MISSING reference profiles: $missing" >&2 + exit 1 + fi +fi diff --git a/scripts/register/02-gateway-ca-config.sh b/scripts/register/02-gateway-ca-config.sh new file mode 100755 index 0000000..869fe5c --- /dev/null +++ b/scripts/register/02-gateway-ca-config.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Stage 02 — register the gateway CA configuration (CAConnection + Templates). +# +# PUTs /config/configuration on the AnyCA REST Gateway: the CERTInext plugin +# connection settings plus the Templates[] array mapping each product_id to its +# certificate profile (created in stage 01) and per-template enrollment params. +# +# STATUS: UNVERIFIED — body shapes built from kfc-in-a-box init-anygateway.sh +# and docs/reference/command/certificate-authority.json. Validate against a live +# gateway before relying on it. +# +# Env (in addition to the command-auth.sh contract): +# GATEWAY_LOGICAL_NAME CA name registered in Command (default: $CONFIGURATION_TENANT) +# GATEWAY_CERT_FILE PEM chain for GatewayRegistration (default: certinext-sandbox-chain.pem) +# CA_CONNECTION_JSON override the entire CAConnection object (advanced) +# TEMPLATE_PARAMS_JSON default per-template Parameters object (default: {}) +# FULL_SCAN_MINUTES default 720; INCR_SCAN_MINUTES default 5 +# DRY_RUN=1 print the body, make no write call +# +# CAConnection is assembled from the CERTINEXT_* env vars (same values the +# integration tests use), keyed by the plugin's ca_plugin_config field names. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +MANIFEST="${MANIFEST:-$REPO_ROOT/integration-manifest.json}" +DRY_RUN="${DRY_RUN:-0}" +GATEWAY_LOGICAL_NAME="${GATEWAY_LOGICAL_NAME:-$CONFIGURATION_TENANT}" +GATEWAY_CERT_FILE="${GATEWAY_CERT_FILE:-$REPO_ROOT/certinext-sandbox-chain.pem}" +# NOTE: do not use ${VAR:-{}} — the first } closes the expansion, appending a +# stray } when VAR is set. Guard with an explicit empty check instead. +[ -n "${TEMPLATE_PARAMS_JSON:-}" ] || TEMPLATE_PARAMS_JSON='{}' +# Per-product CERTInext product code overrides, keyed by product_id, e.g. +# {"DV SSL":"842","OV SSL":"846"}. CERTInext numeric product codes are +# PER-ENVIRONMENT (the plugin's built-in defaults are PRODUCTION codes like +# 838; sandbox accounts use different codes). When a product_id has an entry +# here, Parameters.ProductCode is set so the gateway validates against a code +# that exists in the target account. Discover codes via GetProductDetails +# (scripts/get-product-details.sh). Products not listed fall back to defaults. +[ -n "${PRODUCT_CODE_MAP_JSON:-}" ] || PRODUCT_CODE_MAP_JSON='{}' +FULL_SCAN_MINUTES="${FULL_SCAN_MINUTES:-720}" +INCR_SCAN_MINUTES="${INCR_SCAN_MINUTES:-5}" + +echo "== Stage 02: gateway CA configuration ==" +echo " gateway : $(gw_show)" +echo " logical : $GATEWAY_LOGICAL_NAME" + +# --- CAConnection (CERTInext plugin settings) ------------------------------- +if [ -n "${CA_CONNECTION_JSON:-}" ]; then + CA_CONNECTION="$CA_CONNECTION_JSON" +else + CA_CONNECTION="$(jq -n \ + --arg apiUrl "${CERTINEXT_API_URL:-}" \ + --arg account "${CERTINEXT_ACCOUNT_NUMBER:-}" \ + --arg group "${CERTINEXT_GROUP_NUMBER:-}" \ + --arg org "${CERTINEXT_ORG_NUMBER:-}" \ + --arg authMode "${CERTINEXT_AUTH_MODE:-AccessKey}" \ + --arg apiKey "${CERTINEXT_ACCESS_KEY:-}" \ + --arg reqName "${CERTINEXT_REQUESTOR_NAME:-}" \ + --arg reqEmail "${CERTINEXT_REQUESTOR_EMAIL:-}" \ + --arg reqIsd "${CERTINEXT_REQUESTOR_ISD_CODE:-1}" \ + --arg reqMobile "${CERTINEXT_REQUESTOR_MOBILE:-}" \ + --arg signerPlace "${CERTINEXT_SIGNER_PLACE:-}" \ + --arg signerIp "${CERTINEXT_SIGNER_IP:-}" \ + '{ + ApiUrl: $apiUrl, + AccountNumber: $account, + GroupNumber: $group, + OrganizationNumber: $org, + AuthMode: $authMode, + ApiKey: $apiKey, + RequestorName: $reqName, + RequestorEmail: $reqEmail, + RequestorIsdCode: $reqIsd, + RequestorMobileNumber: $reqMobile, + SignerPlace: $signerPlace, + SignerIp: $signerIp, + Enabled: true + } | with_entries(select(.value != ""))')" +fi + +# --- GatewayRegistration cert ------------------------------------------------ +GATEWAY_CERT_BLOCK='{}' +if [ -f "$GATEWAY_CERT_FILE" ]; then + pem="$(cat "$GATEWAY_CERT_FILE")" + GATEWAY_CERT_BLOCK="$(jq -n --arg pem "$pem" \ + '{Source: "FileUpload", ImportedCertificate: $pem}')" +else + echo " warn: GATEWAY_CERT_FILE not found ($GATEWAY_CERT_FILE) — sending empty cert block" >&2 +fi + +# --- Templates[] (one per product_id) --------------------------------------- +TEMPLATES="$(manifest_product_ids "$MANIFEST" | jq -R . | jq -s \ + --argjson params "$TEMPLATE_PARAMS_JSON" \ + --argjson codes "$PRODUCT_CODE_MAP_JSON" \ + '[.[] | . as $p + | {ProductID: $p, CertificateProfile: $p, + Parameters: ($params + (if $codes[$p] then {ProductCode: $codes[$p]} else {} end))}]')" + +# --- Assemble configuration body -------------------------------------------- +BODY="$(jq -n \ + --argjson caconn "$CA_CONNECTION" \ + --arg logical "$GATEWAY_LOGICAL_NAME" \ + --argjson cert "$GATEWAY_CERT_BLOCK" \ + --argjson full "$FULL_SCAN_MINUTES" \ + --argjson incr "$INCR_SCAN_MINUTES" \ + --argjson templates "$TEMPLATES" \ + '{ + CAConnection: $caconn, + GatewayRegistration: { LogicalName: $logical, GatewayCertificate: $cert }, + ServiceSettings: { + FullScan: { Interval: { Minutes: $full } }, + IncrementalScan: { Interval: { Minutes: $incr } } + }, + Templates: $templates + }')" + +if [ "$DRY_RUN" = "1" ]; then + echo " (dry run) configuration body (ApiKey redacted):" + echo "$BODY" | jq '(.CAConnection.ApiKey) |= (if . then "***" else . end)' + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(gateway_token)" +resp="$(gw_curl "$TOK" PUT /config/configuration "$BODY")" +echo "$resp" | jq -e 'has("error") or has("Message")' >/dev/null 2>&1 \ + && { echo " ! configuration PUT failed: $resp" >&2; exit 1; } +echo "== done: configuration applied for $GATEWAY_LOGICAL_NAME ==" diff --git a/scripts/register/03-gateway-claims.sh b/scripts/register/03-gateway-claims.sh new file mode 100755 index 0000000..eccfa66 --- /dev/null +++ b/scripts/register/03-gateway-claims.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Stage 03 — register gateway access claims (IAM). +# +# POSTs /config/claim for each entry, mapping an OAuth subject to a gateway role. +# Idempotent: claims already present (matched on type+value+role) are skipped. +# +# STATUS: UNVERIFIED — shape from docs/reference/gateway/claims.json and +# kfc-in-a-box init-anygateway.sh. Validate against a live gateway. +# +# Env: +# CLAIMS_JSON JSON array of claim objects to ensure. Default mirrors the +# captured reference: the machine client (admin+user) and the +# human admin (akadmin). Each object: +# {type, value, role, provider, description} +# DRY_RUN=1 print intended actions, no writes +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +DRY_RUN="${DRY_RUN:-0}" + +# OIDC client id drives the machine-client subject (ak-_credentials). +_machine_sub="${CLAIM_MACHINE_SUBJECT:-ak-${OIDC_CLIENT_ID:-anygateway-gateway-certinext-client}_credentials}" +_admin_user="${CLAIM_ADMIN_USER:-akadmin}" +_provider="${CLAIM_PROVIDER:-Authentik}" + +DEFAULT_CLAIMS_JSON="$(jq -n \ + --arg msub "$_machine_sub" --arg admin "$_admin_user" --arg prov "$_provider" \ + '[ + {type:"OAuth_sub", value:$msub, role:"admin", provider:$prov, description:"Authentik machine client"}, + {type:"OAuth_sub", value:$msub, role:"user", provider:$prov, description:"Authentik machine client"}, + {type:"OAuth_sub", value:$admin, role:"admin", provider:$prov, description:"Authentik admin user"} + ]')" +CLAIMS_JSON="${CLAIMS_JSON:-$DEFAULT_CLAIMS_JSON}" + +echo "== Stage 03: gateway claims ==" +echo " gateway : $(gw_show)" + +count="$(echo "$CLAIMS_JSON" | jq 'length')" +echo " claims : $count" + +if [ "$DRY_RUN" = "1" ]; then + echo "$CLAIMS_JSON" | jq -c '.[] | {type, value, role}' + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(gateway_token)" +EXISTING="$(gw_curl "$TOK" GET /config/claim)" + +added=0 skipped=0 +n=0 +while [ "$n" -lt "$count" ]; do + claim="$(echo "$CLAIMS_JSON" | jq -c ".[$n]")" + n=$((n + 1)) + t="$(echo "$claim" | jq -r .type)" + v="$(echo "$claim" | jq -r .value)" + r="$(echo "$claim" | jq -r .role)" + present="$(echo "$EXISTING" | jq --arg t "$t" --arg v "$v" --arg r "$r" \ + 'map(select(.type==$t and .value==$v and .role==$r)) | length' 2>/dev/null || echo 0)" + if [ "${present:-0}" != "0" ]; then + printf ' [skip] %s / %s\n' "$r" "$v" + skipped=$((skipped + 1)) + continue + fi + printf ' [POST] %s / %s\n' "$r" "$v" + resp="$(gw_curl "$TOK" POST /config/claim "$claim")" + echo "$resp" | jq -e 'has("error") or has("Message")' >/dev/null 2>&1 \ + && echo " ! failed: $resp" >&2 + added=$((added + 1)) +done + +echo "== done: $added added, $skipped already present ==" diff --git a/scripts/register/04-command-register-ca.sh b/scripts/register/04-command-register-ca.sh new file mode 100755 index 0000000..48c3bd0 --- /dev/null +++ b/scripts/register/04-command-register-ca.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Stage 04 — register the CA (gateway connector) in Keyfactor Command. +# +# Creates the Certificate Authority record that points Command at the gateway +# tenant, so templates can be imported (stage 05) and used for enrollment. +# Idempotent: looks up by LogicalName first and skips if it already exists. +# +# STATUS: UNVERIFIED — body modeled on docs/reference/command/certificate-authority.json. +# Command CA POST shapes are version-sensitive; validate against your Command. +# +# Env (in addition to the command-auth.sh contract): +# CA_LOGICAL_NAME default: $CONFIGURATION_TENANT +# CA_HOSTNAME gateway tenant URL Command connects to. Default derived: +# https://$GATEWAY_HOST$GATEWAY_BASE_PATH/ejbca +# CA_BODY_JSON override the entire request body (advanced) +# FULL_SCAN_MINUTES default 720; INCR_SCAN_MINUTES default 5 +# DRY_RUN=1 print the body, make no write call +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +DRY_RUN="${DRY_RUN:-0}" +CA_LOGICAL_NAME="${CA_LOGICAL_NAME:-$CONFIGURATION_TENANT}" +CA_HOSTNAME="${CA_HOSTNAME:-${GATEWAY_HOST:+$(gw_base)/ejbca}}" +FULL_SCAN_MINUTES="${FULL_SCAN_MINUTES:-720}" +INCR_SCAN_MINUTES="${INCR_SCAN_MINUTES:-5}" + +echo "== Stage 04: Command CA registration ==" +echo " command : $(cmd_show)" +echo " logical : $CA_LOGICAL_NAME" +echo " host : ${CA_HOSTNAME:-(unset)}" + +if [ -n "${CA_BODY_JSON:-}" ]; then + BODY="$CA_BODY_JSON" +else + BODY="$(jq -n \ + --arg logical "$CA_LOGICAL_NAME" \ + --arg tenant "$CONFIGURATION_TENANT" \ + --arg host "$CA_HOSTNAME" \ + --arg clientId "${OIDC_CLIENT_ID:-}" \ + --arg clientSecret "${OIDC_CLIENT_SECRET:-}" \ + --arg tokenUrl "${TOKEN_URL:-}" \ + --arg scope "$GATEWAY_SCOPE" \ + --argjson full "$FULL_SCAN_MINUTES" \ + --argjson incr "$INCR_SCAN_MINUTES" \ + '{ + LogicalName: $logical, + ConfigurationTenant: $tenant, + ForestRoot: $tenant, + HostName: $host, + CAType: 1, + ClientId: $clientId, + ClientSecret: { SecretValue: $clientSecret }, + TokenURL: $tokenUrl, + Scope: $scope, + UseForEnrollment: true, + UseCAConnector: false, + KeyRetention: 1, + AllowOneClickRenewals: true, + AllowedEnrollmentTypes: 3, + NewEndEntityOnRenewAndReissue: true, + FullScan: { Interval: { Minutes: $full } }, + IncrementalScan: { Interval: { Minutes: $incr } } + }')" +fi + +if [ "$DRY_RUN" = "1" ]; then + echo " (dry run) CA body (secret redacted):" + echo "$BODY" | jq '(.ClientSecret.SecretValue) |= (if . then "***" else . end)' + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(command_token)" + +# Idempotency: skip if a CA with this LogicalName already exists. +EXISTING="$(cmd_curl "$TOK" GET /CertificateAuthority "" 1)" +present="$(echo "$EXISTING" | jq --arg n "$CA_LOGICAL_NAME" \ + 'map(select(.LogicalName==$n)) | length' 2>/dev/null || echo 0)" +if [ "${present:-0}" != "0" ]; then + echo "== CA '$CA_LOGICAL_NAME' already registered — skipping ==" + exit 0 +fi + +resp="$(cmd_curl "$TOK" POST /CertificateAuthority "$BODY" 1)" +echo "$resp" | jq -e 'has("Id")' >/dev/null 2>&1 \ + || { echo " ! CA registration may have failed: $resp" >&2; exit 1; } +echo "== done: CA registered (Id=$(echo "$resp" | jq -r .Id)) ==" diff --git a/scripts/register/05-command-import-templates.sh b/scripts/register/05-command-import-templates.sh new file mode 100755 index 0000000..43e165b --- /dev/null +++ b/scripts/register/05-command-import-templates.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Stage 05 — import gateway templates into Keyfactor Command. +# +# POSTs /Templates/Import for the configured ConfigurationTenant, pulling the +# gateway's product/profile set into Command as AnyCA_ templates. +# (Confirmed working for this tenant by docs/reference/command/templates-certinext.json.) +# +# Env (in addition to the command-auth.sh contract): +# CONFIGURATION_TENANT default certinext-caplugin +# CHECK=1 after import, list templates for the tenant +# DRY_RUN=1 print intended call, no writes +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +DRY_RUN="${DRY_RUN:-0}" +CHECK="${CHECK:-0}" + +echo "== Stage 05: Command template import ==" +echo " command : $(cmd_show)" +echo " tenant : $CONFIGURATION_TENANT" + +BODY="$(jq -n --arg t "$CONFIGURATION_TENANT" '{ConfigurationTenant: $t}')" + +if [ "$DRY_RUN" = "1" ]; then + echo " (dry run) POST /Templates/Import $BODY" + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(command_token)" +resp="$(cmd_curl "$TOK" POST /Templates/Import "$BODY" 1)" +echo " response: $resp" + +if [ "$CHECK" = "1" ]; then + echo "== CHECK: templates for tenant $CONFIGURATION_TENANT ==" + cmd_curl "$TOK" GET /Templates "" 1 \ + | jq -r --arg t "$CONFIGURATION_TENANT" \ + '.[] | select(.ConfigurationTenant==$t) | " - \(.CommonName)"' +fi +echo "== done: import requested for $CONFIGURATION_TENANT ==" diff --git a/scripts/register/06-command-enrollment-patterns.sh b/scripts/register/06-command-enrollment-patterns.sh new file mode 100755 index 0000000..55e45f1 --- /dev/null +++ b/scripts/register/06-command-enrollment-patterns.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Stage 06 — enrollment patterns + template key-retention in Keyfactor Command. +# +# For each imported AnyCA template (ConfigurationTenant = CONFIGURATION_TENANT): +# (a) ensure an enrollment pattern exists and allows enrollment, and +# (b) set the template's private-key retention. +# +# VERIFIED against Command (Portal-proxy /KeyfactorProxy, API v1) on 2026-06-09. +# Schema gotchas baked in from that run — see scripts/register/README.md: +# - EnrollmentPatterns POST: `Template` is an INTEGER (not {Id:..}); +# `AllowedEnrollmentTypes` is PLURAL (singular is silently ignored -> 0); +# `Policies` is REQUIRED ({} is accepted); `TemplateDefault` must be true +# for the template's default pattern; `AssociatedRoles` are role NAME +# strings that must already exist (this instance has "Command Admin", +# NOT "InstanceAdmin"). +# - Update is PUT /EnrollmentPatterns/{id} (collection PUT returns 405). +# - Template retention: PUT /Templates with a partial {Id,KeyRetention, +# KeyRetentionDays} body (other fields are preserved). +# +# Env (in addition to the command-auth.sh contract): +# CONFIGURATION_TENANT template tenant to operate on (= gateway instance +# name, e.g. "certinext-0"). REQUIRED to match anything. +# ENROLL_ROLE role name granted on each pattern (default "Command Admin") +# ENROLL_TYPES AllowedEnrollmentTypes bitmask (default 3 = CSR+PFX) +# PATTERN_PREFIX name prefix for patterns (default "" -> use DisplayName) +# TEMPLATE_KEY_RETENTION KeyRetention value (default "Indefinite"; e.g. "None","Days") +# TEMPLATE_KEY_RETENTION_DAYS default 0 (used when retention is "Days") +# SKIP_PATTERNS=1 only do template retention +# SKIP_FIXUPS=1 only do enrollment patterns +# DRY_RUN=1 print intended actions, no writes +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +DRY_RUN="${DRY_RUN:-0}" +ENROLL_ROLE="${ENROLL_ROLE:-Command Admin}" +ENROLL_TYPES="${ENROLL_TYPES:-3}" +PATTERN_PREFIX="${PATTERN_PREFIX:-}" +TEMPLATE_KEY_RETENTION="${TEMPLATE_KEY_RETENTION:-Indefinite}" +TEMPLATE_KEY_RETENTION_DAYS="${TEMPLATE_KEY_RETENTION_DAYS:-0}" + +echo "== Stage 06: enrollment patterns + template key-retention ==" +echo " command : $(cmd_show)" +echo " tenant : $CONFIGURATION_TENANT role: $ENROLL_ROLE types: $ENROLL_TYPES" +echo " keyret : $TEMPLATE_KEY_RETENTION (days=$TEMPLATE_KEY_RETENTION_DAYS)" + +if [ "$DRY_RUN" = "1" ]; then + echo " (dry run) for each template in tenant '$CONFIGURATION_TENANT':" + [ "${SKIP_PATTERNS:-0}" = "1" ] || echo " - ensure enrollment pattern '${PATTERN_PREFIX}' (role $ENROLL_ROLE, types $ENROLL_TYPES)" + [ "${SKIP_FIXUPS:-0}" = "1" ] || echo " - PUT /Templates KeyRetention=$TEMPLATE_KEY_RETENTION" + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(command_token)" + +# Templates for this tenant (zsh-safe: drive loops via while-read, not word-split). +TEMPLATES="$(cmd_curl "$TOK" GET "/Templates?ReturnLimit=500" "" 1 \ + | jq --arg t "$CONFIGURATION_TENANT" '[.[] | select(.ConfigurationTenant==$t)]')" +tcount="$(echo "$TEMPLATES" | jq 'length')" +echo " templates: $tcount" +if [ "$tcount" -eq 0 ]; then + echo " nothing to do — no templates in tenant '$CONFIGURATION_TENANT'." >&2 + echo " (set CONFIGURATION_TENANT to the gateway instance name; run stage 05 first.)" >&2 + exit 0 +fi + +# --- (a) enrollment patterns ------------------------------------------------- +if [ "${SKIP_PATTERNS:-0}" != "1" ]; then + EXISTING="$(cmd_curl "$TOK" GET /EnrollmentPatterns "" 1)" + echo "$TEMPLATES" | jq -c '.[]' | while IFS= read -r tmpl; do + tid="$(echo "$tmpl" | jq -r .Id)" + disp="$(echo "$tmpl" | jq -r '.DisplayName // .CommonName')" + pname="${PATTERN_PREFIX}${disp}" + body="$(jq -n --arg n "$pname" --argjson t "$tid" --argjson types "$ENROLL_TYPES" \ + --arg role "$ENROLL_ROLE" \ + '{Name:$n, Template:$t, AllowedEnrollmentTypes:$types, TemplateDefault:true, + AssociatedRoles:[$role], Policies:{}}')" + pid="$(echo "$EXISTING" | jq -r --arg n "$pname" \ + 'map(select(.Name==$n)) | (.[0].Id // empty)')" + if [ -n "$pid" ]; then + body="$(echo "$body" | jq --argjson id "$pid" '. + {Id:$id}')" + resp="$(cmd_curl "$TOK" PUT "/EnrollmentPatterns/$pid" "$body" 1)" + verb="PUT id=$pid" + else + resp="$(cmd_curl "$TOK" POST /EnrollmentPatterns "$body" 1)" + verb="POST" + fi + ok="$(echo "$resp" | jq -r 'if .Id then "AllowedEnrollmentTypes=\(.AllowedEnrollmentTypes)" else "ERR: \(.Message//.)" end')" + printf ' [pattern %-9s] %-44s %s\n' "$verb" "$pname" "$ok" + done +fi + +# --- (b) template key-retention --------------------------------------------- +if [ "${SKIP_FIXUPS:-0}" != "1" ]; then + echo "$TEMPLATES" | jq -c '.[]' | while IFS= read -r tmpl; do + tid="$(echo "$tmpl" | jq -r .Id)" + cn="$(echo "$tmpl" | jq -r .CommonName)" + body="$(jq -n --argjson id "$tid" --arg kr "$TEMPLATE_KEY_RETENTION" \ + --argjson days "$TEMPLATE_KEY_RETENTION_DAYS" \ + '{Id:$id, KeyRetention:$kr, KeyRetentionDays:$days}')" + resp="$(cmd_curl "$TOK" PUT /Templates "$body" 1)" + kr="$(echo "$resp" | jq -r '.KeyRetention // ("ERR: "+(.Message//"?"))')" + printf ' [template PUT] %-44s KeyRetention=%s\n' "$cn" "$kr" + done +fi + +echo "== done ==" diff --git a/scripts/register/README.md b/scripts/register/README.md new file mode 100644 index 0000000..a2d31dd --- /dev/null +++ b/scripts/register/README.md @@ -0,0 +1,171 @@ +# CERTInext gateway/Command registration scripts + +Provision the CERTInext AnyCA REST Gateway plugin into the **AnyCA REST Gateway** +and **Keyfactor Command**: gateway certificate profiles, the gateway CA +configuration, Command template import, enrollment patterns, and template +key-retention. Driven by `integration-manifest.json` (`.about.carest.product_ids`) +so it stays in sync with the plugin's products. + +These scripts talk to **Command and the gateway admin API** — *not* the CERTInext +vendor API. Shared auth/host logic lives in [`../lib/command-auth.sh`](../lib/command-auth.sh). + +## Stages + +| Stage | Script | `make` target | Side | Notes | +|------:|--------|---------------|------|-------| +| 01 | `01-gateway-profiles.sh` | `register-profiles` | Gateway | one cert profile per product. **Verified.** | +| 02 | `02-gateway-ca-config.sh` | `register-ca-config` | Gateway | CAConnection + Templates[]. ⚠️ touches CA config — opt-in. | +| 03 | `03-gateway-claims.sh` | `register-claims` | Gateway | OAuth claim→role mappings. Unverified. | +| 04 | `04-command-register-ca.sh` | `register-command-ca` | Command | registers the CA. ⚠️ **CA config — leave alone unless asked.** | +| 05 | `05-command-import-templates.sh` | `register-import` | Command | `POST /Templates/Import`. | +| 06 | `06-command-enrollment-patterns.sh` | `register-enrollment` | Command | enrollment patterns + template key-retention. **Verified.** | +| — | `00-register-all.sh` | `register` | both | runs 01→06; skips missing stages and `SKIP_NN=1`. | + +Every stage: idempotent (GET→POST/PUT), supports `DRY_RUN=1` (offline preview), +and reads `~/.env_certinext` + the env contract below. + +> ⚠️ **Do not modify the CA configuration** (stage 04, and stage 02's CA-connection +> PUT) unless explicitly asked — it is fragile and easily broken. Profiles, +> template import, enrollment patterns, and key-retention are safe to re-run. + +## Authentication + +Three ways to authenticate, resolved per side (gateway vs Command) in this order: + +1. **Session cookie** — `GATEWAY_COOKIE` / `COMMAND_COOKIE`. Paste the full + `cookie:` header value from your browser devtools (Copy-as-cURL) into a file: + ```sh + pbpaste > ~/.certinext_kfcportal_cookie # re-copy the cookie in devtools first + chmod 600 ~/.certinext_kfcportal_cookie + export COMMAND_COOKIE="$(tr -d '\r\n' < ~/.certinext_kfcportal_cookie)" + ``` + The `tr -d` strips the trailing newline (a newline in the header → silent 401). +2. **Bearer token** — `GATEWAY_TOKEN` / `COMMAND_TOKEN` (e.g. copied from an API + request's `authorization: Bearer` header). +3. **OAuth2 client_credentials** — `TOKEN_URL` + `OIDC_CLIENT_ID` + + `OIDC_CLIENT_SECRET` (gateway uses scope `keyfactor-anyca-gateway`). + +### Auth gotchas learned the hard way + +- **The gateway authenticates its admin API with the session cookie directly.** + **Command does not** — a `KeyfactorOIDC*` cookie only works against + **`/KeyfactorProxy`** (the Portal's reverse proxy that injects the bearer), + *not* `/KeyfactorAPI` (which returns 401 for a cookie). The lib auto-selects + `COMMAND_BASE_PATH=/KeyfactorProxy` whenever `COMMAND_COOKIE` is set. +- Cookie mode sends the browser's CSRF headers (`x-requested-with: XMLHttpRequest`) + automatically. +- Tokens/cookies are short-lived; a `401` mid-run usually just means re-grab. + +## Environment contract + +| Var | Used by | Notes | +|-----|---------|-------| +| `GATEWAY_HOST` | gateway stages | host only, no scheme | +| `GATEWAY_BASE_PATH` | gateway stages | **the gateway instance mount path** — e.g. `/certinext-0`, *not* `/AnyGatewayREST` on a multi-instance gateway. Find it in the Portal/Swagger URL. | +| `GATEWAY_COOKIE` / `GATEWAY_TOKEN` | gateway stages | see Authentication | +| `COMMAND_HOST` | command stages | host only | +| `COMMAND_BASE_PATH` | command stages | auto: `/KeyfactorProxy` if cookie, else `/KeyfactorAPI` | +| `COMMAND_COOKIE` / `COMMAND_TOKEN` | command stages | see Authentication | +| `CONFIGURATION_TENANT` | stages 04–06 | **= the gateway instance name** (e.g. `certinext-0`), which is also the templates' `ConfigurationTenant` in Command. Not the plugin name. | +| `CURL_INSECURE` | all | `1` (default) passes `-k`; set `0` to verify TLS | + +## Quick start + +The **typical** path is OAuth2 client_credentials against `/KeyfactorAPI`: + +```sh +export GATEWAY_HOST= COMMAND_HOST= +export TOKEN_URL=https:///application/o/token/ +export OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=... +make register-profiles # client_creds used automatically (no cookie/token set) +``` + +> **Cookie auth (e.g. the "HV3" lab, intdev01.lab.kfpki.com)** — used when ops +> can't issue client credentials. This is environment-specific, NOT the norm: +> the gateway instance path is `/certinext-0` (not `/AnyGatewayREST`), and a +> Command Portal cookie only works via `/KeyfactorProxy` (auto-selected when +> `COMMAND_COOKIE` is set). See the deployment's own notes for its values. +> +> ```sh +> # gateway side +> export GATEWAY_HOST=intdev01.lab.kfpki.com GATEWAY_BASE_PATH=/certinext-0 +> export GATEWAY_COOKIE="$(tr -d '\r\n' < ~/.certinext_gw_cookie)" +> make register-profiles # CHECK=1 to verify, DRY_RUN=1 to preview +> +> # command side (after templates imported) +> export COMMAND_HOST=intdev01.lab.kfpki.com CONFIGURATION_TENANT=certinext-0 +> export COMMAND_COOKIE="$(tr -d '\r\n' < ~/.certinext_kfcportal_cookie)" +> make register-enrollment # stage 06: patterns + KeyRetention=Indefinite +> ``` + +Per-stage env knobs are documented in each script's header comment. + +## Stage 02 — gateway CA config (verified 2026-06-09) + +The gateway CA config (`PUT //config/configuration`) is what maps each +product to a certificate profile so enrollment can resolve a CA. Two things bite: + +- **Product codes are per-environment.** The plugin's built-in `DefaultProductCodes` + are PRODUCTION codes (e.g. `DV SSL` → `838`). A sandbox account has different + numeric codes (e.g. `842`–`851`) and the gateway validates them at PUT time — + you'll get `Profile '838' was not found in CERTInext. Available profiles: …`. + Set `PRODUCT_CODE_MAP_JSON` (product_id → code) so each `Templates[].Parameters` + carries the right `ProductCode`. Discover codes via `scripts/get-product-details.sh`. + Product **IDs/names** are stable across environments; only the numeric codes differ. +- **`SignerPlace` is required by CERTInext** for every order. It has no fallback + (unlike `SignerIp`, which defaults to `127.0.0.1`). If it's absent the order + fails with a generic `certificate request failed … see CA logs`. Provide it via + `CERTINEXT_SIGNER_PLACE` (the test fixture uses `"Gateway"`); the stage assembles + it into `CAConnection`. +- The gateway has **no GET** for `/config/configuration` (405, POST/PUT only) — it's + not introspectable, so a PUT sends the FULL object. Stage 02 rebuilds `CAConnection` + from the `CERTINEXT_*` env vars; make sure those match the account the CA uses, or + you'll change the live connection. (A successful PUT means the creds validated.) + +```sh +export GATEWAY_LOGICAL_NAME=CertiNext # the live CA's LogicalName +export CERTINEXT_SIGNER_PLACE=Gateway +export PRODUCT_CODE_MAP_JSON='{"DV SSL":"842","OV SSL":"846", ...}' +make register-ca-config +``` + +## Stage 06 — Command EnrollmentPatterns schema (verified 2026-06-09) + +The `/KeyfactorProxy/EnrollmentPatterns` (API v1) POST body that works — the stub +originally got every one of these wrong: + +```json +{ + "Name": "AnyCA (DV SSL)", + "Template": 1, // INTEGER, not {"Id":1} + "AllowedEnrollmentTypes": 3, // PLURAL (singular is ignored → 0 = no enroll). 3 = CSR+PFX + "TemplateDefault": true, // required for a template's default pattern + "AssociatedRoles": ["Command Admin"],// role NAME strings that must already exist + "Policies": {} // REQUIRED; empty object is accepted +} +``` + +- **Update** an existing pattern with `PUT /EnrollmentPatterns/{id}` (collection + `PUT` returns **405**). +- Role names are instance-specific — this Command has **`Command Admin`**, not + `InstanceAdmin`. Check `GET /Security/Roles` and set `ENROLL_ROLE` accordingly. + +### Template key-retention + +`PUT /Templates` with a **partial** body — other fields are preserved: + +```json +{ "Id": 1, "KeyRetention": "Indefinite", "KeyRetentionDays": 0 } +``` + +Set via `TEMPLATE_KEY_RETENTION` (default `Indefinite`). Imported templates +default to `None`, so this is needed to retain private keys. + +## Environment notes for whoever runs this + +- **macOS ships bash 3.2** and the default shell is often **zsh**. The scripts use + `#!/usr/bin/env bash` and avoid bash-4 features (`mapfile`) + zsh word-split + pitfalls (loops use `while read`, not `for x in $unquoted`). Keep it that way + if you edit them. +- `docs/reference/` holds captured "known-good" JSON (profiles, templates, CA, + claims) used as validation oracles (`CHECK=1` on stages 01/05). diff --git a/scripts/reject-all-pending.sh b/scripts/reject-all-pending.sh new file mode 100755 index 0000000..09e1cd3 --- /dev/null +++ b/scripts/reject-all-pending.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Reject ALL pending (pre-issuance) CERTInext orders — to reclaim credits / declutter the +# sandbox. Targets certificateStatusId in {2,24} ("Pending for Approver"). NEVER touches +# issued certs (9 "Certificate Downloaded") or already-rejected orders (13). +# +# Safety: dry-run by default (lists what it WOULD reject). Set REJECT_ALL_PENDING=1 to fire. +# Optional: PAGE_SIZE (default 100), REMARKS. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/lib/certinext-auth.sh" + +DRY=1; [ "${REJECT_ALL_PENDING:-}" = "1" ] && DRY=0 +PAGE_SIZE="${PAGE_SIZE:-100}" +REMARKS="${REMARKS:-Cancelled pending order to reclaim sandbox credits.}" + +report_page() { # $1 = page number + read -r ts txn authKey <<< "$(certinext_meta)" + curl -s -X POST "$CERTINEXT_API_URL/GetOrderReport" -H "Content-Type: application/json" \ + -d "{\"meta\":{\"ver\":\"1.0\",\"ts\":\"$ts\",\"txn\":\"$txn\",\"accountNumber\":\"$CERTINEXT_ACCOUNT_NUMBER\",\"authKey\":\"$authKey\"},\"searchCriteria\":{\"groupNumber\":\"$CERTINEXT_GROUP_NUMBER\",\"pageNumber\":\"$1\",\"pageSize\":\"$PAGE_SIZE\"}}" +} + +# --- Snapshot all pending order numbers up front (before rejecting anything) --- +first=$(report_page 1) +pages=$(echo "$first" | jq -r '.orderDetails.noOfPages // 1') +pending=$(echo "$first" | jq -r '.orderDetails.ordersArray[] | select(.certificateStatusId=="24" or .certificateStatusId=="2") | .orderNumber') +p=2 +while [ "$p" -le "$pages" ]; do + more=$(report_page "$p" | jq -r '.orderDetails.ordersArray[] | select(.certificateStatusId=="24" or .certificateStatusId=="2") | .orderNumber') + [ -n "$more" ] && pending="$pending"$'\n'"$more" + p=$((p+1)) +done +pending=$(echo "$pending" | sed '/^$/d') + +count=$(echo "$pending" | grep -c . || true) +echo "Found $count pending order(s) (certificateStatusId 2/24) across $pages page(s)." + +if [ "$DRY" = "1" ]; then + echo "DRY RUN — set REJECT_ALL_PENDING=1 to reject. First 10:" + echo "$pending" | head -10 | sed 's/^/ /' + exit 0 +fi + +ok=0; fail=0 +while IFS= read -r n; do + [ -z "$n" ] && continue + read -r ts txn authKey <<< "$(certinext_meta)" + st=$(curl -s -X POST "$CERTINEXT_API_URL/RejectOrder" -H "Content-Type: application/json" \ + -d "{\"meta\":{\"ver\":\"1.0\",\"ts\":\"$ts\",\"txn\":\"$txn\",\"accountNumber\":\"$CERTINEXT_ACCOUNT_NUMBER\",\"authKey\":\"$authKey\"},\"orderDetails\":{\"orderNumber\":\"$n\",\"rejectRemarks\":\"$REMARKS\"}}" \ + | jq -r '.meta.status // "?"') + if [ "$st" = "1" ]; then ok=$((ok+1)); else fail=$((fail+1)); echo " FAIL $n (status=$st)"; fi +done <<< "$pending" + +echo "Done. Rejected ok=$ok fail=$fail (of $count)." diff --git a/scripts/reject-order.sh b/scripts/reject-order.sh new file mode 100755 index 0000000..974a071 --- /dev/null +++ b/scripts/reject-order.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Cancel/reject a PENDING CERTInext order (pre-issuance) by order number. +# +# Unlike RevokeOrder (which targets issued certs), RejectOrder cancels an order that +# has not yet been issued — e.g. one parked at EXTERNALVALIDATION awaiting DCV. Whether +# this refunds the consumed credit is a CERTInext billing-policy question; run it on one +# order and check GetProductDetails / your credit balance before/after to confirm. +# +# Required env var: ORDER_NUMBER +# Optional env var: REMARKS (default "Cancelled pending order to reclaim sandbox credits.") +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/lib/certinext-auth.sh" + +if [ -z "${ORDER_NUMBER:-}" ]; then + echo "Usage: ORDER_NUMBER= [REMARKS=...] scripts/reject-order.sh" >&2 + exit 1 +fi + +REMARKS="${REMARKS:-Cancelled pending order to reclaim sandbox credits.}" + +read -r ts txn authKey <<< "$(certinext_meta)" +echo "RejectOrder orderNumber=$ORDER_NUMBER ts=$ts txn=$txn" +curl -s -X POST "$CERTINEXT_API_URL/RejectOrder" \ + -H "Content-Type: application/json" \ + -d "{\"meta\":{\"ver\":\"1.0\",\"ts\":\"$ts\",\"txn\":\"$txn\",\"accountNumber\":\"$CERTINEXT_ACCOUNT_NUMBER\",\"authKey\":\"$authKey\"},\"orderDetails\":{\"orderNumber\":\"$ORDER_NUMBER\",\"rejectRemarks\":\"$REMARKS\"}}" \ +| jq . diff --git a/scripts/v2/accept-agreement.sh b/scripts/v2/accept-agreement.sh new file mode 100755 index 0000000..ebf7320 --- /dev/null +++ b/scripts/v2/accept-agreement.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/agreement — record Subscriber Agreement acceptance. +# Required env var: ORDER_ID +# +# 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. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +ORDER_ID="${ORDER_ID:-}" + +if [ -z "$ORDER_ID" ]; then + echo "Usage: ORDER_ID= scripts/v2/accept-agreement.sh" >&2 + exit 1 +fi + +name="${CERTINEXT_REQUESTOR_NAME:-Keyfactor Gateway Test}" +signerIp="${CERTINEXT_SIGNER_IP:-}" +if [ -z "$signerIp" ]; then signerIp=$(curl -s https://api.ipify.org); fi + +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 . diff --git a/scripts/v2/cancel-ssl-order.sh b/scripts/v2/cancel-ssl-order.sh new file mode 100755 index 0000000..09d3a49 --- /dev/null +++ b/scripts/v2/cancel-ssl-order.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/cancel — withdraw an SSL order before issuance. +# 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. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +ORDER_ID="${ORDER_ID:-}" + +if [ -z "$ORDER_ID" ]; then + echo "Usage: ORDER_ID= scripts/v2/cancel-ssl-order.sh" >&2 + exit 1 +fi + +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 . diff --git a/scripts/v2/create-private-pki-order.sh b/scripts/v2/create-private-pki-order.sh new file mode 100755 index 0000000..e4fe86a --- /dev/null +++ b/scripts/v2/create-private-pki-order.sh @@ -0,0 +1,57 @@ +#!/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 +# +# 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. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +PRODUCT_CODE="${PRODUCT_CODE:-}" +HOSTNAME="${HOSTNAME:-}" +CA_PROFILE_ID="${CA_PROFILE_ID:-}" +MASTER_PRODUCT_ID="${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 + +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 \ + --arg caProfileId "$CA_PROFILE_ID" \ + --arg masterProductId "$MASTER_PRODUCT_ID" \ + --arg hostname "$HOSTNAME" \ + --arg name "$name" \ + --arg email "$email" \ + --arg phone "$phone" \ + '{variant:"intranet-ssl", + caProfileId:$caProfileId, + masterProductId:$masterProductId, + hostname:$hostname, + 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 @-) + +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"' diff --git a/scripts/v2/create-ssl-order.sh b/scripts/v2/create-ssl-order.sh new file mode 100755 index 0000000..785c3c0 --- /dev/null +++ b/scripts/v2/create-ssl-order.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# V2 ssl-certificates — create a new SSL/TLS certificate order. +# Required env vars: PRODUCT_CODE, DOMAIN +# Optional env vars: VARIANT (default dv) +# +# 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. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +PRODUCT_CODE="${PRODUCT_CODE:-}" +DOMAIN="${DOMAIN:-}" +VARIANT="${VARIANT:-dv}" + +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"' diff --git a/scripts/v2/download-certificate-private-pki.sh b/scripts/v2/download-certificate-private-pki.sh new file mode 100755 index 0000000..ad1945f --- /dev/null +++ b/scripts/v2/download-certificate-private-pki.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# V2 private-pki-certificates/{orderId}/certificate — download issued Private PKI certificate. +# 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. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +ORDER_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 . diff --git a/scripts/v2/download-certificate.sh b/scripts/v2/download-certificate.sh new file mode 100755 index 0000000..3da275e --- /dev/null +++ b/scripts/v2/download-certificate.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/certificate — download issued SSL certificate. +# 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. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +ORDER_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 . diff --git a/scripts/v2/get-custom-fields.sh b/scripts/v2/get-custom-fields.sh new file mode 100755 index 0000000..b266293 --- /dev/null +++ b/scripts/v2/get-custom-fields.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# V2 catalog/products/{code}/custom-fields — mandatory + optional custom fields for a product. +# Required env var: PRODUCT_CODE +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +PRODUCT_CODE="${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 . diff --git a/scripts/v2/get-dcv.sh b/scripts/v2/get-dcv.sh new file mode 100755 index 0000000..a91d057 --- /dev/null +++ b/scripts/v2/get-dcv.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/dcv — get DCV challenge artifacts for a domain. +# 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. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +ORDER_ID="${ORDER_ID:-}" +DOMAIN="${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 . diff --git a/scripts/v2/list-domains.sh b/scripts/v2/list-domains.sh new file mode 100755 index 0000000..0909d32 --- /dev/null +++ b/scripts/v2/list-domains.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# V2 domains — list domains already pre-validated under this account. +# DCV does not need to be repeated for domains in this list. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +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 . diff --git a/scripts/v2/list-groups.sh b/scripts/v2/list-groups.sh new file mode 100755 index 0000000..5708490 --- /dev/null +++ b/scripts/v2/list-groups.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# V2 groups — list billing groups accessible to this account. +# Use a groupNumber from here in order bodies to charge a specific cost centre. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +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 . diff --git a/scripts/v2/list-organizations.sh b/scripts/v2/list-organizations.sh new file mode 100755 index 0000000..7fc559a --- /dev/null +++ b/scripts/v2/list-organizations.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# V2 organizations — list pre-vetted organizations available for OV/EV SSL. +# Reference an organizationNumber in order bodies to skip re-vetting. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +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 . diff --git a/scripts/v2/list-products.sh b/scripts/v2/list-products.sh new file mode 100755 index 0000000..ef0aba0 --- /dev/null +++ b/scripts/v2/list-products.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# V2 catalog/products — list all products the account can order. +# Each entry has a stable productCode used in the X-Product-Code header. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +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 . diff --git a/scripts/v2/orders-report.sh b/scripts/v2/orders-report.sh new file mode 100755 index 0000000..178b263 --- /dev/null +++ b/scripts/v2/orders-report.sh @@ -0,0 +1,14 @@ +#!/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. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +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 . diff --git a/scripts/v2/ping.sh b/scripts/v2/ping.sh new file mode 100755 index 0000000..3a1886f --- /dev/null +++ b/scripts/v2/ping.sh @@ -0,0 +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. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +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 . diff --git a/scripts/v2/revoke-private-pki.sh b/scripts/v2/revoke-private-pki.sh new file mode 100755 index 0000000..c3e15f9 --- /dev/null +++ b/scripts/v2/revoke-private-pki.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# V2 private-pki-certificates/{orderId}/revoke — permanently revoke an issued Private PKI certificate. +# Required env var: ORDER_ID +# Optional env var: REASON (default superseded) +# +# RFC 5280 reason values: unspecified, keyCompromise, affiliationChanged, +# superseded, cessationOfOperation, privilegeWithdrawn +# +# 204 No Content = revocation recorded on the customer CA. +# 422 = order not yet issued, or already revoked. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +ORDER_ID="${ORDER_ID:-}" +REASON="${REASON:-superseded}" + +if [ -z "$ORDER_ID" ]; then + echo "Usage: ORDER_ID= [REASON=superseded] scripts/v2/revoke-private-pki.sh" >&2 + exit 1 +fi + +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 . diff --git a/scripts/v2/revoke-ssl.sh b/scripts/v2/revoke-ssl.sh new file mode 100755 index 0000000..0cc6d28 --- /dev/null +++ b/scripts/v2/revoke-ssl.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/revoke — permanently revoke an issued SSL certificate. +# Required env var: ORDER_ID +# Optional env var: REASON (default superseded) +# +# RFC 5280 reason values: unspecified, keyCompromise, caCompromise, affiliationChanged, +# superseded, cessationOfOperation, privilegeWithdrawn +# +# 204 No Content = revocation queued; CRL/OCSP reflect this on next publish. +# 422 = order not yet issued, or already revoked. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +ORDER_ID="${ORDER_ID:-}" +REASON="${REASON:-superseded}" + +if [ -z "$ORDER_ID" ]; then + echo "Usage: ORDER_ID= [REASON=superseded] scripts/v2/revoke-ssl.sh" >&2 + exit 1 +fi + +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 . diff --git a/scripts/v2/submit-csr-private-pki.sh b/scripts/v2/submit-csr-private-pki.sh new file mode 100755 index 0000000..09abf20 --- /dev/null +++ b/scripts/v2/submit-csr-private-pki.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# V2 private-pki-certificates/{orderId}/csr — attach a PEM CSR to a Private PKI order. +# Required env vars: ORDER_ID, CSR_FILE (path to PEM file) +# +# The customer CA signs immediately after CSR submission. +# 204 No Content = CSR accepted. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +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 + +if [ ! -f "$CSR_FILE" ]; then + echo "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 . diff --git a/scripts/v2/submit-csr.sh b/scripts/v2/submit-csr.sh new file mode 100755 index 0000000..9ba2725 --- /dev/null +++ b/scripts/v2/submit-csr.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/csr — attach a PEM CSR to an SSL order. +# Required env vars: ORDER_ID, CSR_FILE (path to PEM file) +# +# 204 No Content = CSR accepted; order advances to pending-agreement. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +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 + +if [ ! -f "$CSR_FILE" ]; then + echo "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 . diff --git a/scripts/v2/track-order.sh b/scripts/v2/track-order.sh new file mode 100755 index 0000000..18e1628 --- /dev/null +++ b/scripts/v2/track-order.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId} — fetch current state of an SSL order. +# Required env var: ORDER_ID +# +# Status values: pending-dcv -> pending-csr -> pending-agreement -> issued +# (or cancelled / revoked) +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +ORDER_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 . diff --git a/scripts/v2/track-private-pki.sh b/scripts/v2/track-private-pki.sh new file mode 100755 index 0000000..9fb2d38 --- /dev/null +++ b/scripts/v2/track-private-pki.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# V2 private-pki-certificates/{orderId} — fetch current state of a Private PKI order. +# Required env var: ORDER_ID +# +# Status values: pending-csr -> issued (or cancelled / revoked). +# Private PKI orders skip vetting because the CA is customer-owned. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +ORDER_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 . diff --git a/scripts/v2/verify-dcv.sh b/scripts/v2/verify-dcv.sh new file mode 100755 index 0000000..9b769e1 --- /dev/null +++ b/scripts/v2/verify-dcv.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/dcv/verify — ask the CA to re-check a DCV artifact. +# 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. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" + +ORDER_ID="${ORDER_ID:-}" +DOMAIN="${DOMAIN:-}" +METHOD="${METHOD:-http-url}" + +if [ -z "$ORDER_ID" ] || [ -z "$DOMAIN" ]; then + echo "Usage: ORDER_ID= DOMAIN= [METHOD=http-url] scripts/v2/verify-dcv.sh" >&2 + exit 1 +fi + +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 . diff --git a/scripts/verify-dcv.sh b/scripts/verify-dcv.sh new file mode 100755 index 0000000..98d3bb7 --- /dev/null +++ b/scripts/verify-dcv.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Required env vars: ORDER_NUMBER, DOMAIN_NAME +# Optional: DCV_METHOD (default: 1 = DNS TXT record) +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/lib/certinext-auth.sh" + +ORDER_NUMBER="${ORDER_NUMBER:?Usage: ORDER_NUMBER= DOMAIN_NAME= [DCV_METHOD=1] scripts/verify-dcv.sh}" +DOMAIN_NAME="${DOMAIN_NAME:?DOMAIN_NAME is required}" +DCV_METHOD="${DCV_METHOD:-1}" + +read -r ts txn authKey <<< "$(certinext_meta)" + +# SOC2 CC6.1: do NOT echo authKey — it is a valid single-use request authenticator. +echo "VerifyDcv orderNumber=$ORDER_NUMBER domainName=$DOMAIN_NAME dcvMethod=$DCV_METHOD ts=$ts txn=$txn" + +# SOX CC6.6: use jq --arg to safely interpolate all user-supplied values into the JSON body, +# preventing shell injection via specially crafted ORDER_NUMBER or DOMAIN_NAME values. +jq -n \ + --arg ver "1.0" \ + --arg ts "$ts" \ + --arg txn "$txn" \ + --arg acct "$CERTINEXT_ACCOUNT_NUMBER" \ + --arg authKey "$authKey" \ + --arg email "$CERTINEXT_REQUESTOR_EMAIL" \ + --arg order "$ORDER_NUMBER" \ + --arg domain "$DOMAIN_NAME" \ + --arg method "$DCV_METHOD" \ + '{ + meta: {ver: $ver, ts: $ts, txn: $txn, accountNumber: $acct, authKey: $authKey}, + dcvDetails: {requestorEmail: $email, orderNumber: $order, domainName: $domain, dcvMethod: $method} + }' \ +| curl -s -X POST "$CERTINEXT_API_URL/VerifyDcv" \ + -H "Content-Type: application/json" \ + --data-binary @- \ +| jq .