|
| 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 | +} |
0 commit comments