Skip to content

Commit 8f17d2b

Browse files
committed
test: add key-algorithm coverage matrix (RSA 2048-8192, ECDSA P-256/384/521, Ed25519, Ed448)
Every existing CSR helper hardcoded RSA-2048, so only RSA-2048 was ever exercised end-to-end. AlgorithmMatrixTests adds a parameterized BouncyCastle CSR generator and two layers (submission/CSR-validity scope, no DCV): - Csr_RoundTripsKeyAlgorithm: offline, always runs. Generates a CSR per key type, re-parses it, asserts the public-key algorithm/size round-trips and the request signature verifies. 10/10 pass. - Enroll_AcceptsKeyAlgorithm: opt-in (CERTINEXT_ALGO_MATRIX=1; creates real sandbox orders). Asserts CERTInext accepts each algorithm at submission; a CA-side rejection becomes an explicit Skip carrying the CA's own message, so the matrix documents real CA support without failing on a limitation.
1 parent 016d239 commit 8f17d2b

1 file changed

Lines changed: 281 additions & 0 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
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.Linq;
8+
using System.Threading.Tasks;
9+
using FluentAssertions;
10+
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;
16+
using Org.BouncyCastle.Crypto.Parameters;
17+
using Org.BouncyCastle.Pkcs;
18+
using Org.BouncyCastle.Security;
19+
using Xunit;
20+
using Xunit.Abstractions;
21+
22+
namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
23+
{
24+
/// <summary>
25+
/// Key-algorithm coverage matrix: RSA 2048/3072/4096/6144/8192, ECDSA P-256/P-384/P-521,
26+
/// Ed25519, and Ed448.
27+
///
28+
/// Motivation: every other test in the suite hardcodes an RSA-2048 CSR, so only RSA-2048
29+
/// certificates were ever exercised end-to-end (and that is all that showed up in Command).
30+
/// 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.
33+
///
34+
/// Two layers, matching the agreed scope (submission / CSR-validity only — no DCV, no issuance):
35+
/// 1. <see cref="Csr_RoundTripsKeyAlgorithm"/> — deterministic, no API, always runs. Proves we
36+
/// emit a structurally valid, self-consistent PKCS#10 CSR for each algorithm (the public key
37+
/// type/size round-trips and the request signature verifies).
38+
/// 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.
42+
///
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.
46+
/// </summary>
47+
public class AlgorithmMatrixTests : IClassFixture<IntegrationTestFixture>
48+
{
49+
/// <summary>Set <c>CERTINEXT_ALGO_MATRIX=1</c> to run the live submission theory (creates real orders).</summary>
50+
private const string OptInFlag = "CERTINEXT_ALGO_MATRIX";
51+
52+
private readonly IntegrationTestFixture _fixture;
53+
private readonly ITestOutputHelper _output;
54+
55+
public AlgorithmMatrixTests(IntegrationTestFixture fixture, ITestOutputHelper output)
56+
{
57+
_fixture = fixture;
58+
_output = output;
59+
}
60+
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+
}
154+
155+
// ---------------------------------------------------------------------------
156+
// Layer 1 — deterministic CSR-validity round-trip (no API, always runs)
157+
// ---------------------------------------------------------------------------
158+
159+
/// <summary>
160+
/// Generates a CSR for the given key type, re-parses it, and asserts the public key
161+
/// algorithm/size round-trips and the request signature verifies. Fully offline.
162+
///
163+
/// Note: RSA-6144 and RSA-8192 key generation is intentionally slow (seconds to tens of
164+
/// seconds) — that cost is inherent to large RSA keygen, not the test.
165+
/// </summary>
166+
[Theory]
167+
[MemberData(nameof(KeyTypes))]
168+
public void Csr_RoundTripsKeyAlgorithm(string tag)
169+
{
170+
var spec = SpecFor(tag);
171+
172+
string pem = GenerateCsrPem($"algo-{tag.ToLowerInvariant().Replace("-", string.Empty)}.example.com", spec);
173+
174+
var request = new Pkcs10CertificationRequest(DerFromPem(pem));
175+
176+
request.Verify().Should().BeTrue($"the {tag} CSR must be self-signed with a verifiable signature");
177+
178+
var pub = request.GetPublicKey();
179+
180+
switch (spec.Kind)
181+
{
182+
case KeyKind.Rsa:
183+
pub.Should().BeOfType<RsaKeyParameters>();
184+
// BouncyCastle generates a modulus of exactly 'Strength' bits (top bit set).
185+
((RsaKeyParameters)pub).Modulus.BitLength.Should().Be(spec.Strength,
186+
$"the RSA modulus must be {spec.Strength} bits");
187+
break;
188+
189+
case KeyKind.Ecdsa:
190+
pub.Should().BeOfType<ECPublicKeyParameters>();
191+
((ECPublicKeyParameters)pub).Parameters.Curve.FieldSize.Should().Be(spec.Strength,
192+
$"the EC field size must be {spec.Strength} bits");
193+
break;
194+
195+
case KeyKind.Ed25519:
196+
pub.Should().BeOfType<Ed25519PublicKeyParameters>();
197+
break;
198+
199+
case KeyKind.Ed448:
200+
pub.Should().BeOfType<Ed448PublicKeyParameters>();
201+
break;
202+
}
203+
204+
_output.WriteLine($"[OK] {tag}: CSR generated ({pem.Length} chars PEM), signature verified, public key type confirmed.");
205+
}
206+
207+
// ---------------------------------------------------------------------------
208+
// Layer 2 — live submission acceptance (opt-in; creates real sandbox orders)
209+
// ---------------------------------------------------------------------------
210+
211+
/// <summary>
212+
/// Submits a real order to CERTInext for each key type and asserts the order is accepted
213+
/// (a CARequestID is returned). A CA-side rejection is reported as an explicit Skip carrying
214+
/// the CA's own error message — so the suite documents which algorithms CERTInext accepts
215+
/// rather than failing on a legitimate CA limitation.
216+
///
217+
/// Opt-in: requires <c>CERTINEXT_ALGO_MATRIX=1</c> because each run creates a real (pending,
218+
/// 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.
220+
/// </summary>
221+
[SkippableTheory]
222+
[MemberData(nameof(KeyTypes))]
223+
public async Task Enroll_AcceptsKeyAlgorithm(string tag)
224+
{
225+
IntegrationSkip.IfNotConfigured(_fixture);
226+
Skip.IfNot(
227+
Environment.GetEnvironmentVariable(OptInFlag) == "1",
228+
$"Set {OptInFlag}=1 to run the live algorithm-submission matrix (creates real sandbox orders).");
229+
230+
var spec = SpecFor(tag);
231+
string cn = $"algo-{tag.ToLowerInvariant().Replace("-", string.Empty)}.example.com";
232+
string csrPem = GenerateCsrPem(cn, spec);
233+
234+
var productInfo = new EnrollmentProductInfo
235+
{
236+
ProductID = _fixture.ProductCode,
237+
ProductParameters = new Dictionary<string, string>
238+
{
239+
[Constants.EnrollmentParam.ProfileId] = _fixture.ProductCode,
240+
[Constants.EnrollmentParam.ProductCode] = _fixture.ProductCode,
241+
[Constants.EnrollmentParam.RequesterName] = _fixture.RequestorName,
242+
[Constants.EnrollmentParam.RequesterEmail] = _fixture.RequestorEmail,
243+
}
244+
};
245+
246+
var sanDict = new Dictionary<string, string[]> { ["DNS"] = new[] { cn } };
247+
248+
var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config);
249+
250+
EnrollmentResult enrollResult = null;
251+
try
252+
{
253+
enrollResult = await plugin.Enroll(
254+
csrPem,
255+
$"CN={cn}",
256+
sanDict,
257+
productInfo,
258+
RequestFormat.PKCS10,
259+
EnrollmentType.New);
260+
}
261+
catch (Exception ex)
262+
{
263+
// Per agreed scope: a CA-side rejection (algorithm not supported, or other
264+
// account/provisioning gap) becomes an explicit Skip carrying the CA's message,
265+
// so the matrix documents real CERTInext support without a hard failure.
266+
_output.WriteLine($"[SKIP] {tag}: CERTInext rejected submission — {ex.Message}");
267+
Skip.If(true,
268+
$"CERTInext did not accept a {tag} order. This may be an unsupported key algorithm " +
269+
$"or an account/provisioning limitation. CA message: {ex.Message}");
270+
}
271+
272+
enrollResult.Should().NotBeNull($"{tag}: Enroll must return a non-null result when accepted");
273+
if (enrollResult == null) return; // satisfies nullable analysis; assertion above already failed
274+
275+
enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(
276+
$"{tag}: a CARequestID must be returned when CERTInext accepts the order");
277+
278+
_output.WriteLine($"[OK] {tag}: CERTInext accepted the order. CARequestID={enrollResult.CARequestID}");
279+
}
280+
}
281+
}

0 commit comments

Comments
 (0)