Skip to content

Commit aa94a65

Browse files
spbsolubleKeyfactor
andauthored
Release v1.0.0 (#9)
* Update generated docs * v1.0.0 (#2) Initial release --------- Co-authored-by: Keyfactor <keyfactor@keyfactor.github.io>
1 parent 0a7313a commit aa94a65

84 files changed

Lines changed: 10562 additions & 426 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/keyfactor-bootstrap-workflow.yml

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,9 @@ on:
1111

1212
jobs:
1313
call-starter-workflow:
14-
uses: keyfactor/actions/.github/workflows/starter.yml@v4
15-
with:
16-
command_token_url: ${{ vars.COMMAND_TOKEN_URL }}
17-
command_hostname: ${{ vars.COMMAND_HOSTNAME }}
18-
command_base_api_path: ${{ vars.COMMAND_API_PATH }}
14+
uses: keyfactor/actions/.github/workflows/starter.yml@v5
1915
secrets:
2016
token: ${{ secrets.V2BUILDTOKEN }}
2117
gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }}
2218
gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }}
2319
scan_token: ${{ secrets.SAST_TOKEN }}
24-
entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }}
25-
entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }}
26-
command_client_id: ${{ secrets.COMMAND_CLIENT_ID }}
27-
command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }}

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ terraform/terraform.tfvars
3333

3434
# macOS
3535
.DS_Store
36+
37+
# Analysis / scratch — never commit
38+
analysis/
39+
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright 2024 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
3+
// At http://www.apache.org/licenses/LICENSE-2.0
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Threading.Tasks;
8+
using FluentAssertions;
9+
using Keyfactor.AnyGateway.Extensions;
10+
using Org.BouncyCastle.Crypto.Parameters;
11+
using Org.BouncyCastle.Pkcs;
12+
using Xunit;
13+
using Xunit.Abstractions;
14+
15+
namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
16+
{
17+
/// <summary>
18+
/// Key-algorithm coverage matrix: RSA 2048/3072/4096/6144/8192, ECDSA P-256/P-384/P-521,
19+
/// Ed25519, and Ed448 (see <see cref="KeyAlgorithms"/>).
20+
///
21+
/// Motivation: every other test in the suite hardcoded an RSA-2048 CSR, so only RSA-2048
22+
/// certificates were ever exercised end-to-end (and that is all that showed up in Command).
23+
/// The plugin takes the CSR as enrollment input and submits it verbatim, so the key
24+
/// algorithm is entirely determined by the CSR.
25+
///
26+
/// This file is the offline / submission-only layer (no DCV, no issuance):
27+
/// 1. <see cref="Csr_RoundTripsKeyAlgorithm"/> — deterministic, no API, always runs. Proves we
28+
/// emit a structurally valid, self-consistent PKCS#10 CSR for each algorithm (the public key
29+
/// type/size round-trips and the request signature verifies).
30+
/// 2. <see cref="Enroll_AcceptsKeyAlgorithm"/> — opt-in (creates real sandbox orders). Proves
31+
/// whether CERTInext *accepts* each algorithm at order submission. A CA-side rejection is
32+
/// reported as an explicit Skip carrying the CA's own message.
33+
///
34+
/// The end-to-end "does CERTInext actually issue this algorithm" matrix (DCV on, one real
35+
/// scrup.org cert per type) lives in <c>DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm</c>
36+
/// and only exists on the DCV build.
37+
/// </summary>
38+
public class AlgorithmMatrixTests : IClassFixture<IntegrationTestFixture>
39+
{
40+
/// <summary>Set <c>CERTINEXT_ALGO_MATRIX=1</c> to run the live submission theory (creates real orders).</summary>
41+
private const string OptInFlag = "CERTINEXT_ALGO_MATRIX";
42+
43+
private readonly IntegrationTestFixture _fixture;
44+
private readonly ITestOutputHelper _output;
45+
46+
public AlgorithmMatrixTests(IntegrationTestFixture fixture, ITestOutputHelper output)
47+
{
48+
_fixture = fixture;
49+
_output = output;
50+
}
51+
52+
public static IEnumerable<object[]> KeyTypes => KeyAlgorithms.AsMemberData;
53+
54+
// ---------------------------------------------------------------------------
55+
// Layer 1 — deterministic CSR-validity round-trip (no API, always runs)
56+
// ---------------------------------------------------------------------------
57+
58+
/// <summary>
59+
/// Generates a CSR for the given key type, re-parses it, and asserts the public key
60+
/// algorithm/size round-trips and the request signature verifies. Fully offline.
61+
///
62+
/// Note: RSA-6144 and RSA-8192 key generation is intentionally slow (seconds to tens of
63+
/// seconds) — that cost is inherent to large RSA keygen, not the test.
64+
/// </summary>
65+
[Theory]
66+
[MemberData(nameof(KeyTypes))]
67+
public void Csr_RoundTripsKeyAlgorithm(string tag)
68+
{
69+
var spec = KeyAlgorithms.For(tag);
70+
71+
string pem = KeyAlgorithms.GenerateCsrPem($"algo-{KeyAlgorithms.Slug(tag)}.example.com", spec);
72+
73+
var request = new Pkcs10CertificationRequest(KeyAlgorithms.DerFromPem(pem));
74+
75+
request.Verify().Should().BeTrue($"the {tag} CSR must be self-signed with a verifiable signature");
76+
77+
var pub = request.GetPublicKey();
78+
79+
switch (spec.Kind)
80+
{
81+
case KeyKind.Rsa:
82+
pub.Should().BeOfType<RsaKeyParameters>();
83+
// BouncyCastle generates a modulus of exactly 'Strength' bits (top bit set).
84+
((RsaKeyParameters)pub).Modulus.BitLength.Should().Be(spec.Strength,
85+
$"the RSA modulus must be {spec.Strength} bits");
86+
break;
87+
88+
case KeyKind.Ecdsa:
89+
pub.Should().BeOfType<ECPublicKeyParameters>();
90+
((ECPublicKeyParameters)pub).Parameters.Curve.FieldSize.Should().Be(spec.Strength,
91+
$"the EC field size must be {spec.Strength} bits");
92+
break;
93+
94+
case KeyKind.Ed25519:
95+
pub.Should().BeOfType<Ed25519PublicKeyParameters>();
96+
break;
97+
98+
case KeyKind.Ed448:
99+
pub.Should().BeOfType<Ed448PublicKeyParameters>();
100+
break;
101+
}
102+
103+
_output.WriteLine($"[OK] {tag}: CSR generated ({pem.Length} chars PEM), signature verified, public key type confirmed.");
104+
}
105+
106+
// ---------------------------------------------------------------------------
107+
// Layer 2 — live submission acceptance (opt-in; creates real sandbox orders)
108+
// ---------------------------------------------------------------------------
109+
110+
/// <summary>
111+
/// Submits a real order to CERTInext for each key type and asserts the order is accepted
112+
/// (a CARequestID is returned). A CA-side rejection is reported as an explicit Skip carrying
113+
/// the CA's own error message — so the suite documents which algorithms CERTInext accepts
114+
/// rather than failing on a legitimate CA limitation.
115+
///
116+
/// Opt-in: requires <c>CERTINEXT_ALGO_MATRIX=1</c> because each run creates a real (pending,
117+
/// non-issued) DV order on the sandbox account. No DCV is performed, so the orders park at
118+
/// EXTERNALVALIDATION and are not cleaned up here. "Accepted at submission" is weaker than
119+
/// "will issue" — see <c>DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm</c> for the
120+
/// end-to-end issuance matrix.
121+
/// </summary>
122+
[SkippableTheory]
123+
[MemberData(nameof(KeyTypes))]
124+
public async Task Enroll_AcceptsKeyAlgorithm(string tag)
125+
{
126+
IntegrationSkip.IfNotConfigured(_fixture);
127+
Skip.IfNot(
128+
Environment.GetEnvironmentVariable(OptInFlag) == "1",
129+
$"Set {OptInFlag}=1 to run the live algorithm-submission matrix (creates real sandbox orders).");
130+
131+
var spec = KeyAlgorithms.For(tag);
132+
string cn = $"algo-{KeyAlgorithms.Slug(tag)}.example.com";
133+
string csrPem = KeyAlgorithms.GenerateCsrPem(cn, spec);
134+
135+
var productInfo = new EnrollmentProductInfo
136+
{
137+
ProductID = _fixture.ProductCode,
138+
ProductParameters = new Dictionary<string, string>
139+
{
140+
[Constants.EnrollmentParam.ProfileId] = _fixture.ProductCode,
141+
[Constants.EnrollmentParam.ProductCode] = _fixture.ProductCode,
142+
[Constants.EnrollmentParam.RequesterName] = _fixture.RequestorName,
143+
[Constants.EnrollmentParam.RequesterEmail] = _fixture.RequestorEmail,
144+
}
145+
};
146+
147+
var sanDict = new Dictionary<string, string[]> { ["DNS"] = new[] { cn } };
148+
149+
var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config);
150+
151+
EnrollmentResult enrollResult = null;
152+
try
153+
{
154+
enrollResult = await plugin.Enroll(
155+
csrPem,
156+
$"CN={cn}",
157+
sanDict,
158+
productInfo,
159+
RequestFormat.PKCS10,
160+
EnrollmentType.New);
161+
}
162+
catch (Exception ex)
163+
{
164+
// Per agreed scope: a CA-side rejection becomes an explicit Skip carrying the CA's
165+
// message (classified so an unsupported algorithm isn't confused with a credit/
166+
// account limitation), so the matrix documents real CERTInext support honestly.
167+
string reason = KeyAlgorithms.ClassifyRejection(ex.Message);
168+
_output.WriteLine($"[SKIP] {tag}: {reason}{ex.Message}");
169+
Skip.If(true, $"CERTInext did not accept a {tag} order: {reason}. CA message: {ex.Message}");
170+
}
171+
172+
enrollResult.Should().NotBeNull($"{tag}: Enroll must return a non-null result when accepted");
173+
if (enrollResult == null) return; // satisfies nullable analysis; assertion above already failed
174+
175+
enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(
176+
$"{tag}: a CARequestID must be returned when CERTInext accepts the order");
177+
178+
_output.WriteLine($"[OK] {tag}: CERTInext accepted the order. CARequestID={enrollResult.CARequestID}");
179+
}
180+
}
181+
}

CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,26 @@
66
<LangVersion>12.0</LangVersion>
77
<IsPackable>false</IsPackable>
88
<IsTestProject>true</IsTestProject>
9+
<!-- Mirror the main project's DcvSupport flag. Default false → DCV test files are excluded
10+
(matches the GA no-DCV build); -p:DcvSupport=true compiles them in with SUPPORTS_DCV. -->
11+
<DcvSupport Condition="'$(DcvSupport)' == ''">false</DcvSupport>
12+
<DefineConstants Condition="'$(DcvSupport)' == 'true'">$(DefineConstants);SUPPORTS_DCV</DefineConstants>
913
</PropertyGroup>
1014

1115
<ItemGroup>
1216
<ProjectReference Include="..\CERTInext\CERTInext.csproj" />
1317
</ItemGroup>
1418

19+
<!-- DCV integration tests + the DNS validator implementations use the v3.3-only
20+
IDomainValidator / IDomainValidatorFactory and the factory constructor. On the
21+
IAnyCAPlugin 3.2.0 (no-DCV) build those don't exist, so exclude these files unless
22+
SUPPORTS_DCV is defined. See issue 0003. -->
23+
<ItemGroup Condition="!$(DefineConstants.Contains('SUPPORTS_DCV'))">
24+
<Compile Remove="DcvLifecycleTests.cs" />
25+
<Compile Remove="CloudflareDomainValidator.cs" />
26+
<Compile Remove="StubDomainValidator.cs" />
27+
</ItemGroup>
28+
1529
<ItemGroup>
1630
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
1731
<PackageReference Include="xunit" Version="2.9.0" />
@@ -21,6 +35,7 @@
2135
</PackageReference>
2236
<PackageReference Include="FluentAssertions" Version="6.12.0" />
2337
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
38+
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
2439
<!-- Suppress TFM support build warnings for transitive dependencies -->
2540
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
2641
<PackageReference Include="System.IO.Pipelines" Version="8.0.0" />
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright 2024 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
3+
// At http://www.apache.org/licenses/LICENSE-2.0
4+
5+
using System;
6+
using System.Collections.Concurrent;
7+
using System.Collections.Generic;
8+
using System.Net.Http;
9+
using System.Net.Http.Headers;
10+
using System.Net.Http.Json;
11+
using System.Text.Json;
12+
using System.Text.Json.Serialization;
13+
using System.Threading;
14+
using System.Threading.Tasks;
15+
using Keyfactor.AnyGateway.Extensions;
16+
17+
namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
18+
{
19+
/// <summary>
20+
/// <see cref="IDomainValidator"/> that publishes and removes DNS TXT records via
21+
/// the Cloudflare v4 API. Intended for integration tests against a real domain.
22+
///
23+
/// Credentials are read from the <see cref="IntegrationTestFixture"/>:
24+
/// <c>CERTINEXT_CF_API_TOKEN</c> and <c>CERTINEXT_CF_ZONE_ID</c>.
25+
/// </summary>
26+
internal sealed class CloudflareDomainValidator : IDomainValidator
27+
{
28+
private const string CfApiBase = "https://api.cloudflare.com/client/v4";
29+
30+
private readonly string _apiToken;
31+
private readonly string _zoneId;
32+
private readonly HttpClient _http;
33+
34+
// Maps staging hostname → Cloudflare record ID so CleanupValidation can delete it
35+
private readonly ConcurrentDictionary<string, string> _stagedRecordIds = new();
36+
37+
public CloudflareDomainValidator(string apiToken, string zoneId)
38+
{
39+
_apiToken = apiToken ?? throw new ArgumentNullException(nameof(apiToken));
40+
_zoneId = zoneId ?? throw new ArgumentNullException(nameof(zoneId));
41+
42+
_http = new HttpClient();
43+
_http.DefaultRequestHeaders.Authorization =
44+
new AuthenticationHeaderValue("Bearer", _apiToken);
45+
}
46+
47+
public void Initialize(IDomainValidatorConfigProvider configProvider) { }
48+
49+
public async Task<DomainValidationResult> StageValidation(string key, string value, CancellationToken cancellationToken)
50+
{
51+
var payload = new
52+
{
53+
type = "TXT",
54+
name = key,
55+
content = value,
56+
ttl = 60
57+
};
58+
59+
var response = await _http.PostAsJsonAsync(
60+
$"{CfApiBase}/zones/{_zoneId}/dns_records",
61+
payload,
62+
cancellationToken);
63+
64+
string body = await response.Content.ReadAsStringAsync(cancellationToken);
65+
66+
if (!response.IsSuccessStatusCode)
67+
return new DomainValidationResult
68+
{
69+
Success = false,
70+
ErrorMessage = $"Cloudflare API error {(int)response.StatusCode}: {body}"
71+
};
72+
73+
using var doc = JsonDocument.Parse(body);
74+
bool success = doc.RootElement.GetProperty("success").GetBoolean();
75+
string recordId = success
76+
? doc.RootElement.GetProperty("result").GetProperty("id").GetString()
77+
: null;
78+
79+
if (!success || string.IsNullOrEmpty(recordId))
80+
return new DomainValidationResult
81+
{
82+
Success = false,
83+
ErrorMessage = $"Cloudflare record creation failed: {body}"
84+
};
85+
86+
_stagedRecordIds[key] = recordId;
87+
88+
return new DomainValidationResult { Success = true };
89+
}
90+
91+
public async Task<DomainValidationResult> CleanupValidation(string key, CancellationToken cancellationToken)
92+
{
93+
if (!_stagedRecordIds.TryRemove(key, out string recordId))
94+
return new DomainValidationResult { Success = true }; // nothing to clean up
95+
96+
var response = await _http.DeleteAsync(
97+
$"{CfApiBase}/zones/{_zoneId}/dns_records/{recordId}",
98+
cancellationToken);
99+
100+
if (!response.IsSuccessStatusCode)
101+
{
102+
string body = await response.Content.ReadAsStringAsync(cancellationToken);
103+
return new DomainValidationResult
104+
{
105+
Success = false,
106+
ErrorMessage = $"Cloudflare delete error {(int)response.StatusCode}: {body}"
107+
};
108+
}
109+
110+
return new DomainValidationResult { Success = true };
111+
}
112+
113+
public Task ValidateConfiguration(Dictionary<string, object> configuration) => Task.CompletedTask;
114+
public Dictionary<string, Keyfactor.AnyGateway.Extensions.PropertyConfigInfo> GetDomainValidatorAnnotations() => new();
115+
public string GetValidationType() => "dns-01";
116+
}
117+
118+
internal sealed class CloudflareDomainValidatorFactory : IDomainValidatorFactory
119+
{
120+
private readonly IDomainValidator _validator;
121+
122+
public CloudflareDomainValidatorFactory(string apiToken, string zoneId)
123+
{
124+
_validator = new CloudflareDomainValidator(apiToken, zoneId);
125+
}
126+
127+
public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator;
128+
}
129+
}

0 commit comments

Comments
 (0)