Skip to content

Commit 394c357

Browse files
committed
test(dcv): add end-to-end key-algorithm issuance matrix
Extract the parameterized BouncyCastle CSR generator + 10-type spec into a shared KeyAlgorithms helper (used by both the offline submission tests and the new DCV theory). Add DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm: for each key type (RSA 2048/3072/4096/6144/8192, ECDSA P-256/384/521, Ed25519, Ed448) enroll a fresh scrup.org DV order with DCV on, drive it to issuance, and assert the issued cert's public key matches the requested algorithm. Algorithms CERTInext won't issue (rejected/FAILED/never-GENERATED) Skip with the observed reason rather than hard-failing. Confirms issuance via targeted GetSingleRecord polling instead of a full-account sync. Opt-in: CERTINEXT_ALGO_MATRIX_DCV=1. Also null-forgive two asserted-non-null derefs in EnrollWithDcvOn_OrderIssued (surfaced now that DcvLifecycleTests compiles on every build via DcvSupport).
1 parent 1d611f0 commit 394c357

3 files changed

Lines changed: 276 additions & 120 deletions

File tree

CERTInext.IntegrationTests/AlgorithmMatrixTests.cs

Lines changed: 19 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,36 @@
44

55
using System;
66
using System.Collections.Generic;
7-
using System.Linq;
87
using System.Threading.Tasks;
98
using FluentAssertions;
109
using Keyfactor.AnyGateway.Extensions;
11-
using Org.BouncyCastle.Asn1;
12-
using Org.BouncyCastle.Asn1.Sec;
13-
using Org.BouncyCastle.Asn1.X509;
14-
using Org.BouncyCastle.Crypto;
15-
using Org.BouncyCastle.Crypto.Generators;
1610
using Org.BouncyCastle.Crypto.Parameters;
1711
using Org.BouncyCastle.Pkcs;
18-
using Org.BouncyCastle.Security;
1912
using Xunit;
2013
using Xunit.Abstractions;
2114

2215
namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
2316
{
2417
/// <summary>
2518
/// Key-algorithm coverage matrix: RSA 2048/3072/4096/6144/8192, ECDSA P-256/P-384/P-521,
26-
/// Ed25519, and Ed448.
19+
/// Ed25519, and Ed448 (see <see cref="KeyAlgorithms"/>).
2720
///
28-
/// Motivation: every other test in the suite hardcodes an RSA-2048 CSR, so only RSA-2048
21+
/// Motivation: every other test in the suite hardcoded an RSA-2048 CSR, so only RSA-2048
2922
/// certificates were ever exercised end-to-end (and that is all that showed up in Command).
3023
/// The plugin takes the CSR as enrollment input and submits it verbatim, so the key
31-
/// algorithm is entirely determined by the CSR. These tests parameterise CSR generation
32-
/// (BouncyCastle — never BCL crypto) across the full matrix.
24+
/// algorithm is entirely determined by the CSR.
3325
///
34-
/// Two layers, matching the agreed scope (submission / CSR-validity only no DCV, no issuance):
26+
/// This file is the offline / submission-only layer (no DCV, no issuance):
3527
/// 1. <see cref="Csr_RoundTripsKeyAlgorithm"/> — deterministic, no API, always runs. Proves we
3628
/// emit a structurally valid, self-consistent PKCS#10 CSR for each algorithm (the public key
3729
/// type/size round-trips and the request signature verifies).
3830
/// 2. <see cref="Enroll_AcceptsKeyAlgorithm"/> — opt-in (creates real sandbox orders). Proves
39-
/// whether CERTInext *accepts* each algorithm at order submission. A CA-side rejection
40-
/// (e.g. "algorithm not supported") is reported as an explicit Skip carrying the CA's own
41-
/// message, so the suite documents real CA support rather than failing on a CA limitation.
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.
4233
///
43-
/// Caveat: "accepted at submission" is weaker than "will issue". A public CA may accept the
44-
/// order and only reject an exotic key (Ed25519/Ed448, very large RSA) at issuance, after DCV.
45-
/// End-to-end issuance per algorithm would require the DCV build + a Cloudflare round per order.
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.
4637
/// </summary>
4738
public class AlgorithmMatrixTests : IClassFixture<IntegrationTestFixture>
4839
{
@@ -58,99 +49,7 @@ public AlgorithmMatrixTests(IntegrationTestFixture fixture, ITestOutputHelper ou
5849
_output = output;
5950
}
6051

61-
// ---------------------------------------------------------------------------
62-
// Key-algorithm specifications
63-
// ---------------------------------------------------------------------------
64-
65-
private enum KeyKind { Rsa, Ecdsa, Ed25519, Ed448 }
66-
67-
private sealed class KeySpec
68-
{
69-
public string Tag; // stable, human-readable id ("RSA-2048", "ECDSA-P256", ...)
70-
public KeyKind Kind;
71-
public int Strength; // RSA modulus bits, or EC field size in bits (informational for Ed)
72-
public string SignatureAlgorithm; // BouncyCastle signature-algorithm name for the CSR
73-
public DerObjectIdentifier CurveOid; // EC named-curve OID (null for non-EC)
74-
}
75-
76-
// CA/Baseline-Requirements hash pairing: P-256→SHA256, P-384→SHA384, P-521→SHA512.
77-
private static readonly KeySpec[] Specs =
78-
{
79-
new() { Tag = "RSA-2048", Kind = KeyKind.Rsa, Strength = 2048, SignatureAlgorithm = "SHA256withRSA" },
80-
new() { Tag = "RSA-3072", Kind = KeyKind.Rsa, Strength = 3072, SignatureAlgorithm = "SHA256withRSA" },
81-
new() { Tag = "RSA-4096", Kind = KeyKind.Rsa, Strength = 4096, SignatureAlgorithm = "SHA256withRSA" },
82-
new() { Tag = "RSA-6144", Kind = KeyKind.Rsa, Strength = 6144, SignatureAlgorithm = "SHA256withRSA" },
83-
new() { Tag = "RSA-8192", Kind = KeyKind.Rsa, Strength = 8192, SignatureAlgorithm = "SHA256withRSA" },
84-
new() { Tag = "ECDSA-P256", Kind = KeyKind.Ecdsa, Strength = 256, SignatureAlgorithm = "SHA256withECDSA", CurveOid = SecObjectIdentifiers.SecP256r1 },
85-
new() { Tag = "ECDSA-P384", Kind = KeyKind.Ecdsa, Strength = 384, SignatureAlgorithm = "SHA384withECDSA", CurveOid = SecObjectIdentifiers.SecP384r1 },
86-
new() { Tag = "ECDSA-P521", Kind = KeyKind.Ecdsa, Strength = 521, SignatureAlgorithm = "SHA512withECDSA", CurveOid = SecObjectIdentifiers.SecP521r1 },
87-
new() { Tag = "Ed25519", Kind = KeyKind.Ed25519, Strength = 256, SignatureAlgorithm = "Ed25519" },
88-
new() { Tag = "Ed448", Kind = KeyKind.Ed448, Strength = 448, SignatureAlgorithm = "Ed448" },
89-
};
90-
91-
private static KeySpec SpecFor(string tag) => Specs.Single(s => s.Tag == tag);
92-
93-
/// <summary>xUnit member-data source — one row per key type, keyed by its stable tag.</summary>
94-
public static IEnumerable<object[]> KeyTypes => Specs.Select(s => new object[] { s.Tag });
95-
96-
// ---------------------------------------------------------------------------
97-
// CSR generation (BouncyCastle)
98-
// ---------------------------------------------------------------------------
99-
100-
private static AsymmetricCipherKeyPair GenerateKeyPair(KeySpec spec)
101-
{
102-
switch (spec.Kind)
103-
{
104-
case KeyKind.Rsa:
105-
{
106-
var gen = new RsaKeyPairGenerator();
107-
gen.Init(new KeyGenerationParameters(new SecureRandom(), spec.Strength));
108-
return gen.GenerateKeyPair();
109-
}
110-
case KeyKind.Ecdsa:
111-
{
112-
var gen = new ECKeyPairGenerator("ECDSA");
113-
gen.Init(new ECKeyGenerationParameters(spec.CurveOid, new SecureRandom()));
114-
return gen.GenerateKeyPair();
115-
}
116-
case KeyKind.Ed25519:
117-
{
118-
var gen = new Ed25519KeyPairGenerator();
119-
gen.Init(new Ed25519KeyGenerationParameters(new SecureRandom()));
120-
return gen.GenerateKeyPair();
121-
}
122-
case KeyKind.Ed448:
123-
{
124-
var gen = new Ed448KeyPairGenerator();
125-
gen.Init(new Ed448KeyGenerationParameters(new SecureRandom()));
126-
return gen.GenerateKeyPair();
127-
}
128-
default:
129-
throw new ArgumentOutOfRangeException(nameof(spec), spec.Kind, "unhandled key kind");
130-
}
131-
}
132-
133-
private static string GenerateCsrPem(string commonName, KeySpec spec)
134-
{
135-
var keyPair = GenerateKeyPair(spec);
136-
var subject = new X509Name($"CN={commonName}");
137-
var csr = new Pkcs10CertificationRequest(spec.SignatureAlgorithm, subject, keyPair.Public, null, keyPair.Private);
138-
139-
return "-----BEGIN CERTIFICATE REQUEST-----\n"
140-
+ Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
141-
+ "\n-----END CERTIFICATE REQUEST-----";
142-
}
143-
144-
private static byte[] DerFromPem(string pem)
145-
{
146-
var b64 = pem
147-
.Replace("-----BEGIN CERTIFICATE REQUEST-----", string.Empty)
148-
.Replace("-----END CERTIFICATE REQUEST-----", string.Empty)
149-
.Replace("\r", string.Empty)
150-
.Replace("\n", string.Empty)
151-
.Trim();
152-
return Convert.FromBase64String(b64);
153-
}
52+
public static IEnumerable<object[]> KeyTypes => KeyAlgorithms.AsMemberData;
15453

15554
// ---------------------------------------------------------------------------
15655
// Layer 1 — deterministic CSR-validity round-trip (no API, always runs)
@@ -167,11 +66,11 @@ private static byte[] DerFromPem(string pem)
16766
[MemberData(nameof(KeyTypes))]
16867
public void Csr_RoundTripsKeyAlgorithm(string tag)
16968
{
170-
var spec = SpecFor(tag);
69+
var spec = KeyAlgorithms.For(tag);
17170

172-
string pem = GenerateCsrPem($"algo-{tag.ToLowerInvariant().Replace("-", string.Empty)}.example.com", spec);
71+
string pem = KeyAlgorithms.GenerateCsrPem($"algo-{KeyAlgorithms.Slug(tag)}.example.com", spec);
17372

174-
var request = new Pkcs10CertificationRequest(DerFromPem(pem));
73+
var request = new Pkcs10CertificationRequest(KeyAlgorithms.DerFromPem(pem));
17574

17675
request.Verify().Should().BeTrue($"the {tag} CSR must be self-signed with a verifiable signature");
17776

@@ -216,7 +115,9 @@ public void Csr_RoundTripsKeyAlgorithm(string tag)
216115
///
217116
/// Opt-in: requires <c>CERTINEXT_ALGO_MATRIX=1</c> because each run creates a real (pending,
218117
/// non-issued) DV order on the sandbox account. No DCV is performed, so the orders park at
219-
/// EXTERNALVALIDATION and are not cleaned up here.
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.
220121
/// </summary>
221122
[SkippableTheory]
222123
[MemberData(nameof(KeyTypes))]
@@ -227,9 +128,9 @@ public async Task Enroll_AcceptsKeyAlgorithm(string tag)
227128
Environment.GetEnvironmentVariable(OptInFlag) == "1",
228129
$"Set {OptInFlag}=1 to run the live algorithm-submission matrix (creates real sandbox orders).");
229130

230-
var spec = SpecFor(tag);
231-
string cn = $"algo-{tag.ToLowerInvariant().Replace("-", string.Empty)}.example.com";
232-
string csrPem = GenerateCsrPem(cn, spec);
131+
var spec = KeyAlgorithms.For(tag);
132+
string cn = $"algo-{KeyAlgorithms.Slug(tag)}.example.com";
133+
string csrPem = KeyAlgorithms.GenerateCsrPem(cn, spec);
233134

234135
var productInfo = new EnrollmentProductInfo
235136
{

CERTInext.IntegrationTests/DcvLifecycleTests.cs

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Org.BouncyCastle.Asn1.X509;
1010
using Org.BouncyCastle.Crypto;
1111
using Org.BouncyCastle.Crypto.Generators;
12+
using Org.BouncyCastle.Crypto.Parameters;
1213
using Org.BouncyCastle.Pkcs;
1314
using Org.BouncyCastle.Security;
1415
using FluentAssertions;
@@ -360,14 +361,149 @@ public async Task EnrollWithDcvOn_OrderIssuedEndToEnd_AndAppearsInSync()
360361
fetched.Status.Should().Be((int)EndEntityStatus.GENERATED);
361362
fetched.Certificate.Should().NotBeNullOrWhiteSpace(
362363
"GetSingleRecord must populate the PEM for a GENERATED order.");
363-
_output.WriteLine($" Sync cert PEM length: {record.Certificate.Length}; " +
364-
$"GetSingleRecord PEM length: {fetched.Certificate.Length}");
364+
_output.WriteLine($" Sync cert PEM length: {record.Certificate!.Length}; " +
365+
$"GetSingleRecord PEM length: {fetched.Certificate!.Length}");
365366
}
366367

367368
_output.WriteLine($"--- Verdict: DCV-on enroll for {cn} drove DCV end-to-end via plugin, " +
368369
$"order {enrollResult.CARequestID} surfaced in sync with Status={record.Status}. ---");
369370
}
370371

372+
/// <summary>
373+
/// End-to-end key-algorithm issuance matrix: RSA 2048/3072/4096/6144/8192, ECDSA
374+
/// P-256/P-384/P-521, Ed25519, Ed448 (see <see cref="KeyAlgorithms"/>). For each type,
375+
/// enroll a fresh scrup.org DV order with DCV ON, drive it to issuance via the plugin
376+
/// (Cloudflare TXT publish → VerifyDcv → bounded sync passes), and assert the issued cert
377+
/// carries a parseable body whose public key matches the requested algorithm.
378+
///
379+
/// An algorithm CERTInext won't issue — rejected at submission, FAILED, or never reaching
380+
/// GENERATED within the polling window — is reported as an explicit Skip carrying the
381+
/// observed reason, so the matrix documents which algorithms CERTInext actually issues
382+
/// without hard-failing on a legitimate CA limitation.
383+
///
384+
/// Opt-in (issues a real cert per accepted algorithm): set <c>CERTINEXT_ALGO_MATRIX_DCV=1</c>.
385+
/// Requires Cloudflare DCV credentials.
386+
/// </summary>
387+
[SkippableTheory]
388+
[MemberData(nameof(KeyAlgorithms.AsMemberData), MemberType = typeof(KeyAlgorithms))]
389+
public async Task EnrollWithDcvOn_IssuesPerKeyAlgorithm(string tag)
390+
{
391+
IntegrationSkip.IfNotConfigured(_fixture);
392+
Skip.If(System.Environment.GetEnvironmentVariable("CERTINEXT_ALGO_MATRIX_DCV") != "1",
393+
"Opt-in: set CERTINEXT_ALGO_MATRIX_DCV=1 to issue one real scrup.org cert per key algorithm.");
394+
Skip.If(!_fixture.IsCloudflareConfigured,
395+
"CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV issuance must publish real TXT records.");
396+
397+
var spec = KeyAlgorithms.For(tag);
398+
string suffix = System.Guid.NewGuid().ToString("N").Substring(0, 8);
399+
string cn = $"algo-{KeyAlgorithms.Slug(tag)}-{suffix}.scrup.org";
400+
string csr = KeyAlgorithms.GenerateCsrPem(cn, spec);
401+
402+
var plugin = BuildPlugin(dcvEnabled: true);
403+
404+
// --- Enroll. A submission-time rejection (unsupported algorithm) → Skip with the CA's reason. ---
405+
EnrollmentResult enrollResult;
406+
try
407+
{
408+
enrollResult = await plugin.Enroll(
409+
csr: csr,
410+
subject: $"CN={cn}",
411+
san: new Dictionary<string, string[]> { ["dns"] = new[] { cn } },
412+
productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode),
413+
requestFormat: RequestFormat.PKCS10,
414+
enrollmentType: EnrollmentType.New);
415+
}
416+
catch (Exception ex)
417+
{
418+
_output.WriteLine($"[SKIP] {tag}: CERTInext rejected the DV order at submission — {ex.Message}");
419+
Skip.If(true, $"CERTInext did not accept a {tag} DV order (likely an unsupported key algorithm). CA message: {ex.Message}");
420+
return; // unreachable — Skip throws
421+
}
422+
423+
enrollResult.Should().NotBeNull();
424+
enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace($"{tag}: CA must return a CARequestID when it accepts the order");
425+
_output.WriteLine($"[{tag}] enrolled cn={cn} id={enrollResult.CARequestID} status={enrollResult.Status}");
426+
427+
// --- Poll this one order to issuance via GetSingleRecord (targeted; avoids the
428+
// full-account sync, which would also drive DCV on unrelated pending orders). ---
429+
const int maxPolls = 6;
430+
const int delaySeconds = 15;
431+
AnyCAPluginCertificate record = null;
432+
for (int poll = 1; poll <= maxPolls; poll++)
433+
{
434+
record = await plugin.GetSingleRecord(enrollResult.CARequestID);
435+
int status = record?.Status ?? -1;
436+
_output.WriteLine($"[{tag}] poll #{poll}: status={status} certLen={record?.Certificate?.Length ?? 0}");
437+
438+
if (status == (int)EndEntityStatus.GENERATED)
439+
break;
440+
if (status == (int)EndEntityStatus.FAILED)
441+
{
442+
_output.WriteLine($"[SKIP] {tag}: order {enrollResult.CARequestID} went FAILED — CERTInext will not issue this algorithm.");
443+
Skip.If(true, $"CERTInext FAILED the {tag} order — algorithm not issuable on this account/profile.");
444+
return;
445+
}
446+
if (poll < maxPolls)
447+
await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
448+
}
449+
450+
record.Should().NotBeNull($"{tag}: enrolled order {enrollResult.CARequestID} must be retrievable");
451+
452+
if (record!.Status != (int)EndEntityStatus.GENERATED)
453+
{
454+
// Accepted at submission but not issued within the window — document as Skip, not fail.
455+
_output.WriteLine($"[SKIP] {tag}: order {enrollResult.CARequestID} still Status={record.Status} after {maxPolls} polls.");
456+
Skip.If(true, $"CERTInext accepted the {tag} order but it did not reach GENERATED within the polling window " +
457+
$"(Status={record.Status}) — possible unsupported algorithm or slow server-side validation.");
458+
return;
459+
}
460+
461+
record.Certificate.Should().NotBeNullOrWhiteSpace(
462+
$"{tag}: issued cert must carry a PEM body (issue 0001)");
463+
464+
// Strong check: the issued cert's public key must match the algorithm we requested.
465+
AssertIssuedCertMatchesAlgorithm(record.Certificate, spec, tag);
466+
467+
_output.WriteLine($"--- {tag}: DCV-on issuance OK — order {enrollResult.CARequestID} GENERATED, " +
468+
$"cert public key confirmed as {tag}. ---");
469+
}
470+
471+
/// <summary>
472+
/// Parses an issued certificate PEM and asserts its public key matches the requested
473+
/// algorithm/size — proves CERTInext issued the key type we submitted, not a substitute.
474+
/// </summary>
475+
private static void AssertIssuedCertMatchesAlgorithm(string certPem, KeyAlgorithmSpec spec, string tag)
476+
{
477+
var b64 = certPem
478+
.Replace("-----BEGIN CERTIFICATE-----", string.Empty)
479+
.Replace("-----END CERTIFICATE-----", string.Empty)
480+
.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim();
481+
482+
var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64));
483+
cert.Should().NotBeNull($"{tag}: issued cert PEM must parse");
484+
485+
var pub = cert.GetPublicKey();
486+
switch (spec.Kind)
487+
{
488+
case KeyKind.Rsa:
489+
pub.Should().BeOfType<RsaKeyParameters>();
490+
((RsaKeyParameters)pub).Modulus.BitLength.Should().Be(spec.Strength,
491+
$"{tag}: issued RSA cert must have a {spec.Strength}-bit modulus");
492+
break;
493+
case KeyKind.Ecdsa:
494+
pub.Should().BeOfType<ECPublicKeyParameters>();
495+
((ECPublicKeyParameters)pub).Parameters.Curve.FieldSize.Should().Be(spec.Strength,
496+
$"{tag}: issued EC cert must use a {spec.Strength}-bit curve");
497+
break;
498+
case KeyKind.Ed25519:
499+
pub.Should().BeOfType<Ed25519PublicKeyParameters>();
500+
break;
501+
case KeyKind.Ed448:
502+
pub.Should().BeOfType<Ed448PublicKeyParameters>();
503+
break;
504+
}
505+
}
506+
371507
/// <summary>
372508
/// Exercises the deferred-DCV retry path during single-record refresh against an
373509
/// existing pending order. Reads <c>CERTINEXT_PENDING_ORDER_ID</c> from the

0 commit comments

Comments
 (0)