Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
6735bbc
feat: add CHANGELOG for 1.0.0 release
spbsoluble Apr 21, 2026
9bf5fea
Update generated docs
Apr 21, 2026
262f5b1
fix: correct integration manifest and template section heading
spbsoluble Apr 21, 2026
b088396
fix: correct release_dir, populate product_ids, fix template section …
spbsoluble Apr 21, 2026
848da99
Update generated docs
Apr 21, 2026
ff58681
feat: return human-readable product names from GetProductIds
spbsoluble Apr 21, 2026
e8d9066
Update generated docs
Apr 21, 2026
b7e2213
feat: hardcode GetProductIds and auto-map product names to numeric codes
spbsoluble Apr 21, 2026
86a5bcf
fix: restore product_ids after doc-tool rebase
spbsoluble Apr 21, 2026
dff3762
Update generated docs
Apr 21, 2026
fc815c3
fix: hardcode GetProductIds so doc tool can populate product_ids via …
spbsoluble Apr 22, 2026
c73c738
Update generated docs
Apr 22, 2026
9a14685
docs: include architecture.md in configuration.md via doctool pre-render
spbsoluble Apr 22, 2026
913f9fe
docs: add Mechanics section to configuration.md
spbsoluble Apr 22, 2026
bb157e1
Update generated docs
Apr 22, 2026
ee8426a
docs: add missing SSL/TLS products (841/845/847) and note portal-only…
spbsoluble Apr 22, 2026
c2dcff7
fix: add CODE override to generate-order Makefile target
spbsoluble Apr 22, 2026
26be507
Update generated docs
Apr 22, 2026
c56dfad
docs: fix duplicate Architecture heading and remove unimplemented SAN…
spbsoluble Apr 22, 2026
7acaf5a
Update generated docs
Apr 22, 2026
35f68bb
fix: use correct authKey field name in architecture.md; remove dead S…
spbsoluble Apr 22, 2026
391877c
Update generated docs
Apr 22, 2026
4f62a95
chore: Refactor Makefile
spbsoluble Apr 24, 2026
b6dc1b8
Merge branch 'main' into feat/v1.0-release-notes
indrora Apr 30, 2026
05395cd
Update generated docs
Apr 30, 2026
fd6c432
feat(dcv): DNS domain control validation via IDomainValidatorFactory …
spbsoluble May 6, 2026
3928720
build: target net8.0 and net10.0
spbsoluble May 6, 2026
ad6c969
feat(config): add DcvTimeoutMinutes with CERTINEXT_DCV_TIMEOUT_MINUTE…
spbsoluble May 6, 2026
9e06a4c
chore(manifest): add DCV config fields to ca_plugin_config
spbsoluble May 6, 2026
aea1ec0
test(dcv): FakeDomainValidator, DCV unit tests, Cloudflare integratio…
spbsoluble May 6, 2026
6a2db50
feat: complete SSL order body, sync DCV retry, and bounded Enroll waits
spbsoluble May 19, 2026
6062e8e
fix(dcv): run post-DCV issuance wait when DCV is already validated
spbsoluble May 20, 2026
ce8e02d
fix(plugin): make IDomainValidatorFactory injection optional (#7)
spbsoluble May 21, 2026
fc723ca
test(plugin): behavioral coverage for issue #7 factory-injection path
spbsoluble May 21, 2026
7ce9971
test(integration): live "DCV off + sync" verification for issue #7
spbsoluble May 21, 2026
797f666
test(integration): live "DCV on" verification mirroring the DCV-off test
spbsoluble May 21, 2026
3b8b52c
perf(sync): single-shot DCV retry — drop per-order challenge polling
spbsoluble May 21, 2026
dd82989
test(integration): DCV-on test asserts PEM via GetSingleRecord, not sync
spbsoluble May 21, 2026
63ed82f
refactor(crypto): remove System.Security.Cryptography refs, use Bounc…
spbsoluble May 21, 2026
507325f
build(deps): bump BouncyCastle.Cryptography 2.0.0 -> 2.6.2, drop redu…
spbsoluble May 21, 2026
6e01ac0
chore(tests): silence CS8602/CS8604 nullable-deref warnings in test code
spbsoluble May 21, 2026
fa4eac4
docs: surface both sandbox and production product codes in configurat…
spbsoluble May 21, 2026
8503a8f
docs: strip real account/group/org identifiers, use clearly-placehold…
spbsoluble May 21, 2026
255e3ad
fix+chore: audit triage (dev-review + SOX/SOC2 compliance pass)
spbsoluble May 21, 2026
d4467e9
Merge remote-tracking branch 'origin/release-1.0' into feat/v1.0-rele…
spbsoluble May 21, 2026
054b2c3
fix: type field as object + drop dead nested helper (issue #7 follow-up)
spbsoluble May 22, 2026
aab1847
feat+fix: address issue #8 (diagnostic + rate-limit + env-file)
spbsoluble May 22, 2026
1d8c0dc
fix: compliance triage on issue #7 follow-up + issue #8
spbsoluble May 22, 2026
f63e164
Update generated docs
May 22, 2026
59a1039
docs: add QUICKSTART.md — end-to-end CERTInext + Command setup
spbsoluble May 22, 2026
4df0357
docs: add reference/ JSON captures from a known-working CERTInext lab
spbsoluble May 22, 2026
642ad9d
docs(QUICKSTART): link reference JSON per step
spbsoluble May 22, 2026
1675b9e
docs(overview): add CERTInext CA certificates section with portal links
spbsoluble May 22, 2026
603d6bd
feat(scripts): add gateway/Command registration tooling
spbsoluble Jun 9, 2026
b760cc8
feat(scripts): stage 02 per-env product codes; document SignerPlace +…
spbsoluble Jun 9, 2026
49d1281
docs(scripts): client_credentials is the norm; mark cookie/KeyfactorP…
spbsoluble Jun 9, 2026
6c2e0cb
fix(sync): materialize cert bodies + bound DCV-during-sync (issues 00…
spbsoluble Jun 10, 2026
60fd195
Update generated docs
Jun 10, 2026
0d00ec2
build: target IAnyCAPlugin 3.2.0 (no-DCV variant for 25.5.0 hosts)
spbsoluble Jun 10, 2026
31be598
chore(logging): verbose Debug/Trace across the sync flow
spbsoluble Jun 10, 2026
016d239
fix(sync): preserve listing metadata on refetch; honest DCV summary; …
spbsoluble Jun 10, 2026
8f17d2b
test: add key-algorithm coverage matrix (RSA 2048-8192, ECDSA P-256/3…
spbsoluble Jun 10, 2026
1d611f0
build: collapse the no-DCV fork into a DcvSupport build flag
spbsoluble Jun 11, 2026
394c357
test(dcv): add end-to-end key-algorithm issuance matrix
spbsoluble Jun 11, 2026
82c2984
test(dcv): classify CA rejection — unsupported algorithm vs out-of-cr…
spbsoluble Jun 11, 2026
71fa546
scripts: add reject-order + reject-all-pending (cancel pending CERTIn…
spbsoluble Jun 11, 2026
edd8b21
test(dcv): poll for GENERATED *with body*, not just GENERATED
spbsoluble Jun 11, 2026
99add21
build: default DcvSupport to false (ship the GA-host 3.2.0 build)
spbsoluble Jun 11, 2026
5a72895
Update generated docs
Jun 11, 2026
0539684
chore(ci): Update build workflow to v5
spbsoluble Jun 11, 2026
254a2c6
fix(ci): restore required gpg_key/gpg_pass secrets for starter.yml@v5
spbsoluble Jun 11, 2026
f81679d
docs: auto-generate README and documentation [skip ci]
github-actions[bot] Jun 11, 2026
8583f22
manifest: set gateway_framework to 25.5.0 (matches shipped no-DCV 3.2…
spbsoluble Jun 11, 2026
bc7de97
docs: auto-generate README and documentation [skip ci]
github-actions[bot] Jun 11, 2026
2d30d73
docs(changelog): reformat to conventional-commit sections (k8s-orches…
spbsoluble Jun 11, 2026
50a67dd
docs: apply docs-review corrections to source docs
spbsoluble Jun 12, 2026
bdda3bd
docs(tests): rewrite CERTInext.Tests/TESTING.md to match the real tes…
spbsoluble Jun 12, 2026
4b1e502
docs: auto-generate README and documentation [skip ci]
github-actions[bot] Jun 12, 2026
e97b386
docs: regenerate README install paths to net8.0/net10.0 [skip ci]
spbsoluble Jun 12, 2026
b937baf
manifest: mark v1.0 production / kf-supported; regenerate README [ski…
spbsoluble Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions .github/workflows/keyfactor-bootstrap-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ terraform/terraform.tfvars

# macOS
.DS_Store

# Analysis / scratch — never commit
analysis/

181 changes: 181 additions & 0 deletions CERTInext.IntegrationTests/AlgorithmMatrixTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Key-algorithm coverage matrix: RSA 2048/3072/4096/6144/8192, ECDSA P-256/P-384/P-521,
/// Ed25519, and Ed448 (see <see cref="KeyAlgorithms"/>).
///
/// 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. <see cref="Csr_RoundTripsKeyAlgorithm"/> — 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. <see cref="Enroll_AcceptsKeyAlgorithm"/> — 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 <c>DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm</c>
/// and only exists on the DCV build.
/// </summary>
public class AlgorithmMatrixTests : IClassFixture<IntegrationTestFixture>
{
/// <summary>Set <c>CERTINEXT_ALGO_MATRIX=1</c> to run the live submission theory (creates real orders).</summary>
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<object[]> KeyTypes => KeyAlgorithms.AsMemberData;

// ---------------------------------------------------------------------------
// Layer 1 — deterministic CSR-validity round-trip (no API, always runs)
// ---------------------------------------------------------------------------

/// <summary>
/// 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.
/// </summary>
[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<RsaKeyParameters>();
// 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>();
((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<Ed25519PublicKeyParameters>();
break;

case KeyKind.Ed448:
pub.Should().BeOfType<Ed448PublicKeyParameters>();
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)
// ---------------------------------------------------------------------------

/// <summary>
/// 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 <c>CERTINEXT_ALGO_MATRIX=1</c> 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 <c>DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm</c> for the
/// end-to-end issuance matrix.
/// </summary>
[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<string, string>
{
[Constants.EnrollmentParam.ProfileId] = _fixture.ProductCode,
[Constants.EnrollmentParam.ProductCode] = _fixture.ProductCode,
[Constants.EnrollmentParam.RequesterName] = _fixture.RequestorName,
[Constants.EnrollmentParam.RequesterEmail] = _fixture.RequestorEmail,
}
};

var sanDict = new Dictionary<string, string[]> { ["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}");
}
}
}
15 changes: 15 additions & 0 deletions CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,26 @@
<LangVersion>12.0</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Mirror the main project's DcvSupport flag. Default false → DCV test files are excluded
(matches the GA no-DCV build); -p:DcvSupport=true compiles them in with SUPPORTS_DCV. -->
<DcvSupport Condition="'$(DcvSupport)' == ''">false</DcvSupport>
<DefineConstants Condition="'$(DcvSupport)' == 'true'">$(DefineConstants);SUPPORTS_DCV</DefineConstants>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\CERTInext\CERTInext.csproj" />
</ItemGroup>

<!-- DCV integration tests + the DNS validator implementations use the v3.3-only
IDomainValidator / IDomainValidatorFactory and the factory constructor. On the
IAnyCAPlugin 3.2.0 (no-DCV) build those don't exist, so exclude these files unless
SUPPORTS_DCV is defined. See issue 0003. -->
<ItemGroup Condition="!$(DefineConstants.Contains('SUPPORTS_DCV'))">
<Compile Remove="DcvLifecycleTests.cs" />
<Compile Remove="CloudflareDomainValidator.cs" />
<Compile Remove="StubDomainValidator.cs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.9.0" />
Expand All @@ -21,6 +35,7 @@
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<!-- Suppress TFM support build warnings for transitive dependencies -->
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
<PackageReference Include="System.IO.Pipelines" Version="8.0.0" />
Expand Down
129 changes: 129 additions & 0 deletions CERTInext.IntegrationTests/CloudflareDomainValidator.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// <see cref="IDomainValidator"/> 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 <see cref="IntegrationTestFixture"/>:
/// <c>CERTINEXT_CF_API_TOKEN</c> and <c>CERTINEXT_CF_ZONE_ID</c>.
/// </summary>
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<string, string> _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<DomainValidationResult> 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<DomainValidationResult> 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<string, object> configuration) => Task.CompletedTask;
public Dictionary<string, Keyfactor.AnyGateway.Extensions.PropertyConfigInfo> 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;
}
}
Loading