|
| 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