From 58e081dc51218e16a18bc5de312437e028d3cefe Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 16:01:59 -0600 Subject: [PATCH 01/13] Recipient-aware enveloping: implementation plan --- ...6-06-20-scep-recipient-aware-enveloping.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-scep-recipient-aware-enveloping.md diff --git a/docs/superpowers/plans/2026-06-20-scep-recipient-aware-enveloping.md b/docs/superpowers/plans/2026-06-20-scep-recipient-aware-enveloping.md new file mode 100644 index 0000000..abf5058 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-scep-recipient-aware-enveloping.md @@ -0,0 +1,25 @@ +# SCEP Recipient-Aware Enveloping + Cert-Usage Conformance — Plan + +**Branch:** `feature/scep-recipient-aware` (off main after Phase 4 / PR #5 merged). +**Origin:** Designed collaboratively 2026-06-20 (post-Phase-4). Emerged from the realization that SCEP allows separate signing vs. encryption certificates, so the EnvelopedData recipient must be chosen by capability — and that an ML-DSA/SLH-DSA-only server has no encryption-capable key at all. + +**Goal:** Make the client choose the SCEP EnvelopedData recipient from the `GetCACert` bundle correctly (by KeyUsage, with a positional mode), pick the `RecipientInfo` type by the recipient cert's algorithm (RSA `KeyTrans`, EC `KeyAgree`, ML-KEM `KEMRecipientInfo`), and surface conformance findings. Plumb the fake server to present every signing/encryption cert combination via parallel per-profile endpoints. **Out of scope:** generating an actual RFC 9629 `KEMRecipientInfo` (BC 2.5.0 has no generator) — built-in provider emits a capability-gated finding; the seam is left ready for an external provider or a future hand-roll, and for testing against a real ML-KEM server. + +**Additive only:** no `IScepCrypto` signature change. `PkiMessage.RecipientCaCert` already carries the recipient; we only change *which* cert is selected and how the provider branches on it. + +## Key facts established (see memory [[scep-bouncycastle-cms-reference]]) +- RFC 8894 does **not** mandate position vs. KeyUsage for distinguishing RA signing/encryption certs — both are conventions. Default to KeyUsage; support positional; flag disagreement. +- A single dual-use cert is valid: RSA (`digitalSignature`+`keyEncipherment`) or EC (`digitalSignature`+`keyAgreement`). +- Recipient algorithm → RecipientInfo: RSA(`keyEncipherment`)→`KeyTrans`; EC(`keyAgreement`)→`KeyAgree`; ML-KEM(`keyEncipherment`)→`KEMRecipientInfo`; ML-DSA/SLH-DSA→none (signature-only, cannot be a recipient). +- BC 2.5.0: `AddKeyTransRecipient` ✓, `AddKeyAgreementRecipient` ✓, no KEM recipient generator (only the `KemRecipientInfo` ASN.1 type). + +## Tasks (TDD, granular commits; implemented in-session) + +1. **RecipientSelector (Core) + cert-usage classification.** Pure function: `(certs, strategy) → { SigningCert, EncryptionCert?, RecipientKind, Findings }`. Strategy = KeyUsage (default) | Positional. Classify each cert by SPKI OID → RSA/EC/ML-KEM/SignatureOnly and by KeyUsage bits. Findings: no encryption-capable cert; KeyUsage/position disagreement; missing KeyUsage extension. Tests cover single-RSA, single-EC, split combos, ML-DSA-only (→ finding), and disagreement. *(Server-agnostic; highest value first.)* +2. **Wire selection into the enroll path.** Replace blind `ca.Value[0]` recipient with `RecipientSelector` result; keep signing cert for response verification (unaffected). Surface findings via the existing Trace/Opinion + test report channels. +3. **Provider RecipientInfo branching (`BcPkiMessage`).** Branch envelope on recipient SPKI algorithm: RSA→`AddKeyTransRecipient` (have), EC→`AddKeyAgreementRecipient` (new), ML-KEM→error → Core finding. Capability flag in `CryptoCapabilities` so the finding is capability-driven. +4. **Fake server per-profile endpoints.** `/scep/{profile}` routes, each backed by a `TestCa` configured for a cert combo: `rsa-dual`, `ec-dual`, `ecdsa-rsa`, `ecdsa-ecdh`, `mldsa-rsa`, `mldsa-mlkem`, `mldsa-only` (+ KeyUsage/order variants). `TestCa` generates signing+encryption certs of chosen algorithms with chosen KeyUsage and assembles the degenerate PKCS#7 in chosen order. Server-side decryption: RSA (have) + EC ECDH (new); ML-KEM cert is *presented only*. +5. **End-to-end tests per endpoint.** RSA round-trip (have), EC round-trip (new), ML-DSA-sign+RSA-encrypt round-trip (the realistic PQ case), ML-KEM-presented → client finding, ML-DSA-only → "cannot envelope" finding. Plus a standalone "RA cert usage" conformance check (verifies a server set its KeyUsage bits correctly). + +## Deferred +- RFC 9629 `KEMRecipientInfo` generation (client) + decapsulation (fake server) — the contained hand-roll, to be done when pointing at a real ML-KEM server or loading a KEM-capable provider. From aed0672a1a738402ae547268efceac6d2e7e3124 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 16:04:54 -0600 Subject: [PATCH 02/13] Recipient-aware: RecipientSelector + cert-usage classification/conformance findings --- .../Recipients/RecipientSelector.cs | 176 ++++++++++++++++++ .../Fakes/TestCertFactory.cs | 97 ++++++++++ .../RecipientSelectorTests.cs | 120 ++++++++++++ 3 files changed, 393 insertions(+) create mode 100644 src/ScepTestClient.Core/Recipients/RecipientSelector.cs create mode 100644 tests/ScepTestClient.Tests/Fakes/TestCertFactory.cs create mode 100644 tests/ScepTestClient.Tests/RecipientSelectorTests.cs diff --git a/src/ScepTestClient.Core/Recipients/RecipientSelector.cs b/src/ScepTestClient.Core/Recipients/RecipientSelector.cs new file mode 100644 index 0000000..7daa93e --- /dev/null +++ b/src/ScepTestClient.Core/Recipients/RecipientSelector.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography.X509Certificates; + +namespace ScepTestClient.Core.Recipients; + +// How a candidate cert's key would be used as the SCEP EnvelopedData recipient. +public enum RecipientKind { KeyTransport, KeyAgreement, Kem, SignatureOnly, Unknown } + +// SCEP does not mandate position vs. KeyUsage for distinguishing RA signing/encryption certs +// (RFC 8894 leaves it undefined). KeyUsage is the semantically-correct default; Positional +// reproduces the NDES-style convention some servers/clients rely on. +public enum RecipientStrategy { KeyUsage, Positional } + +public sealed record RecipientFinding(string Code, string Message); + +public sealed class RecipientSelection { + public X509Certificate2? SigningCertificate { get; init; } + public X509Certificate2? EncryptionCertificate { get; init; } + public RecipientKind EncryptionKind { get; init; } = RecipientKind.Unknown; + public IReadOnlyList Findings { get; init; } = Array.Empty(); + + public bool CanEnvelope => EncryptionCertificate is not null + && (EncryptionKind == RecipientKind.KeyTransport + || EncryptionKind == RecipientKind.KeyAgreement + || EncryptionKind == RecipientKind.Kem); +} + +// Chooses the SCEP signing and encryption certificates from a GetCACert bundle and reports +// conformance findings (e.g. a server that presents only signature-capable certs, or one whose +// KeyUsage bits do not match the key's algorithm). +public static class RecipientSelector { + private const string OidRsa = "1.2.840.113549.1.1.1"; + private const string OidEc = "1.2.840.10045.2.1"; + private const string OidMlKemArc = "2.16.840.1.101.3.4.4."; + private const string OidPqSignatureArc = "2.16.840.1.101.3.4.3."; // ML-DSA + SLH-DSA signature arc + + public static RecipientKind ClassifyAlgorithm(string spki_oid) { + if (spki_oid == OidRsa) { return RecipientKind.KeyTransport; } + if (spki_oid == OidEc) { return RecipientKind.KeyAgreement; } + if (spki_oid.StartsWith(OidMlKemArc, StringComparison.Ordinal)) { return RecipientKind.Kem; } + if (spki_oid.StartsWith(OidPqSignatureArc, StringComparison.Ordinal)) { return RecipientKind.SignatureOnly; } + return RecipientKind.Unknown; + } + + public static RecipientSelection Select(IReadOnlyList certs, RecipientStrategy strategy = RecipientStrategy.KeyUsage) { + if (certs is null || certs.Count == 0) { + return new RecipientSelection { + Findings = new[] { new RecipientFinding("no-certificates", "GetCACert returned no certificates") }, + }; + } + + return strategy == RecipientStrategy.Positional ? SelectPositional(certs) : SelectByKeyUsage(certs); + } + + private static RecipientSelection SelectByKeyUsage(IReadOnlyList certs) { + X509Certificate2? sign_cert; + X509Certificate2? enc_cert; + RecipientKind enc_kind; + bool enc_capable_by_algorithm; + bool enc_rejected_for_keyusage; + List findings; + + sign_cert = null; + enc_cert = null; + enc_kind = RecipientKind.Unknown; + enc_capable_by_algorithm = false; + enc_rejected_for_keyusage = false; + findings = new List(); + + foreach (X509Certificate2 cert in certs) { + string oid; + RecipientKind kind; + X509KeyUsageFlags? usage; + + oid = cert.GetKeyAlgorithm(); + kind = ClassifyAlgorithm(oid); + usage = ReadKeyUsage(cert); + + if (sign_cert is null && CanSign(kind) && UsageAllowsSigning(usage)) { + sign_cert = cert; + } + + if (CanEncrypt(kind)) { + enc_capable_by_algorithm = true; + if (enc_cert is null) { + if (UsageAllowsEncryption(usage, kind)) { + enc_cert = cert; + enc_kind = kind; + if (usage is null) { + findings.Add(new RecipientFinding("no-keyusage-extension", + "encryption certificate has no KeyUsage extension; accepting on algorithm capability")); + } + } else { + enc_rejected_for_keyusage = true; + } + } + } + } + + if (sign_cert is null && certs.Count > 0) { + sign_cert = certs[0]; + } + + if (enc_cert is null) { + if (enc_capable_by_algorithm && enc_rejected_for_keyusage) { + findings.Add(new RecipientFinding("encryption-keyusage-missing", + "server presents an encryption-capable key but its KeyUsage lacks keyEncipherment/keyAgreement; PKIOperation cannot be enveloped")); + } else { + findings.Add(new RecipientFinding("no-encryption-cert", + "server presents only signature-capable certificate(s); SCEP PKIOperation requires an encryption-capable recipient and cannot be enveloped")); + } + } else if (certs.Count > 1 && !ReferenceEquals(enc_cert, certs[1])) { + findings.Add(new RecipientFinding("keyusage-position-mismatch", + "the encryption certificate selected by KeyUsage is not the second certificate; position-based clients may pick the wrong one")); + } + + return new RecipientSelection { + SigningCertificate = sign_cert, + EncryptionCertificate = enc_cert, + EncryptionKind = enc_kind, + Findings = findings, + }; + } + + private static RecipientSelection SelectPositional(IReadOnlyList certs) { + X509Certificate2 sign_cert; + X509Certificate2 enc_cert; + RecipientKind enc_kind; + List findings; + + findings = new List(); + sign_cert = certs[0]; + enc_cert = certs.Count > 1 ? certs[1] : certs[0]; + enc_kind = ClassifyAlgorithm(enc_cert.GetKeyAlgorithm()); + + if (!CanEncrypt(enc_kind)) { + findings.Add(new RecipientFinding("no-encryption-cert", + "the positionally-selected encryption certificate is signature-only; PKIOperation cannot be enveloped")); + return new RecipientSelection { SigningCertificate = sign_cert, Findings = findings }; + } + + return new RecipientSelection { + SigningCertificate = sign_cert, + EncryptionCertificate = enc_cert, + EncryptionKind = enc_kind, + Findings = findings, + }; + } + + private static bool CanSign(RecipientKind kind) => + kind == RecipientKind.KeyTransport || kind == RecipientKind.KeyAgreement || kind == RecipientKind.SignatureOnly; + + private static bool CanEncrypt(RecipientKind kind) => + kind == RecipientKind.KeyTransport || kind == RecipientKind.KeyAgreement || kind == RecipientKind.Kem; + + private static bool UsageAllowsSigning(X509KeyUsageFlags? usage) => + usage is null + || (usage.Value & (X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.KeyCertSign)) != 0; + + private static bool UsageAllowsEncryption(X509KeyUsageFlags? usage, RecipientKind kind) { + if (usage is null) { return true; } + if (kind == RecipientKind.KeyAgreement) { + return (usage.Value & X509KeyUsageFlags.KeyAgreement) != 0; + } + return (usage.Value & X509KeyUsageFlags.KeyEncipherment) != 0; + } + + private static X509KeyUsageFlags? ReadKeyUsage(X509Certificate2 cert) { + X509KeyUsageExtension? ext; + + ext = cert.Extensions.OfType().FirstOrDefault(); + return ext?.KeyUsages; + } +} diff --git a/tests/ScepTestClient.Tests/Fakes/TestCertFactory.cs b/tests/ScepTestClient.Tests/Fakes/TestCertFactory.cs new file mode 100644 index 0000000..17bdae9 --- /dev/null +++ b/tests/ScepTestClient.Tests/Fakes/TestCertFactory.cs @@ -0,0 +1,97 @@ +using System; +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Asn1.Sec; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; + +namespace ScepTestClient.Tests.Fakes; + +// Mints X.509 certificates of a chosen algorithm with chosen KeyUsage bits, for recipient-selection +// tests and the fake server's per-profile GetCACert bundles. BC-direct (independent of our provider). +// algorithm: "rsa" | "ec" | "ml-dsa" | "ml-kem". KeyUsage bits use Org.BouncyCastle.Asn1.X509.KeyUsage. +internal static class TestCertFactory { + public static X509Certificate2 Make(string algorithm, int bc_key_usage) { + AsymmetricCipherKeyPair pair; + string sig_alg; + ISignatureFactory signer; + + pair = GenerateKeyPair(algorithm, out sig_alg); + + // ML-KEM cannot self-sign (it is a KEM, not a signature scheme); issue from a throwaway RSA CA. + if (algorithm.Equals("ml-kem", StringComparison.OrdinalIgnoreCase)) { + AsymmetricCipherKeyPair ca_pair; + string ca_sig; + + ca_pair = GenerateKeyPair("rsa", out ca_sig); + signer = new Asn1SignatureFactory("SHA256WITHRSA", ca_pair.Private); + return Build(pair.Public, signer, "CN=test-ca", "CN=test-kem", bc_key_usage); + } + + signer = new Asn1SignatureFactory(sig_alg, pair.Private); + return Build(pair.Public, signer, "CN=test", "CN=test", bc_key_usage); + } + + private static AsymmetricCipherKeyPair GenerateKeyPair(string algorithm, out string sig_alg) { + SecureRandom random; + + random = new SecureRandom(); + switch (algorithm.ToLowerInvariant()) { + case "rsa": { + RsaKeyPairGenerator rsa_gen; + + rsa_gen = new RsaKeyPairGenerator(); + rsa_gen.Init(new KeyGenerationParameters(random, 2048)); + sig_alg = "SHA256WITHRSA"; + return rsa_gen.GenerateKeyPair(); + } + case "ec": { + ECKeyPairGenerator ec_gen; + + ec_gen = new ECKeyPairGenerator(); + ec_gen.Init(new ECKeyGenerationParameters(SecObjectIdentifiers.SecP256r1, random)); + sig_alg = "SHA256WITHECDSA"; + return ec_gen.GenerateKeyPair(); + } + case "ml-dsa": { + MLDsaKeyPairGenerator mldsa_gen; + + mldsa_gen = new MLDsaKeyPairGenerator(); + mldsa_gen.Init(new MLDsaKeyGenerationParameters(random, MLDsaParameters.ml_dsa_65)); + sig_alg = "2.16.840.1.101.3.4.3.18"; + return mldsa_gen.GenerateKeyPair(); + } + case "ml-kem": { + MLKemKeyPairGenerator mlkem_gen; + + mlkem_gen = new MLKemKeyPairGenerator(); + mlkem_gen.Init(new MLKemKeyGenerationParameters(random, MLKemParameters.ml_kem_768)); + sig_alg = string.Empty; + return mlkem_gen.GenerateKeyPair(); + } + default: + throw new ArgumentException($"unsupported test cert algorithm '{algorithm}'"); + } + } + + private static X509Certificate2 Build(AsymmetricKeyParameter subject_public, ISignatureFactory signer, string issuer_dn, string subject_dn, int bc_key_usage) { + X509V3CertificateGenerator cg; + + cg = new X509V3CertificateGenerator(); + cg.SetSerialNumber(BigInteger.ValueOf(System.DateTime.UtcNow.Ticks & 0x7fffffff)); + cg.SetIssuerDN(new X509Name(issuer_dn)); + cg.SetSubjectDN(new X509Name(subject_dn)); + cg.SetNotBefore(System.DateTime.UtcNow.AddMinutes(-5)); + cg.SetNotAfter(System.DateTime.UtcNow.AddYears(1)); + cg.SetPublicKey(subject_public); + if (bc_key_usage != 0) { + cg.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(bc_key_usage)); + } + return new X509Certificate2(cg.Generate(signer).GetEncoded()); + } +} diff --git a/tests/ScepTestClient.Tests/RecipientSelectorTests.cs b/tests/ScepTestClient.Tests/RecipientSelectorTests.cs new file mode 100644 index 0000000..080e5e9 --- /dev/null +++ b/tests/ScepTestClient.Tests/RecipientSelectorTests.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Asn1.X509; +using ScepTestClient.Core.Recipients; +using ScepTestClient.Tests.Fakes; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class RecipientSelectorTests { + [Theory] + [InlineData("1.2.840.113549.1.1.1", RecipientKind.KeyTransport)] + [InlineData("1.2.840.10045.2.1", RecipientKind.KeyAgreement)] + [InlineData("2.16.840.1.101.3.4.4.2", RecipientKind.Kem)] + [InlineData("2.16.840.1.101.3.4.3.18", RecipientKind.SignatureOnly)] + [InlineData("1.3.6.1.4.1.99999", RecipientKind.Unknown)] + public void Classifies_algorithm_by_oid(string oid, RecipientKind expected) { + Assert.Equal(expected, RecipientSelector.ClassifyAlgorithm(oid)); + } + + [Fact] + public void Single_dual_use_rsa_is_signing_and_encryption() { + X509Certificate2 cert; + RecipientSelection selection; + + cert = TestCertFactory.Make("rsa", KeyUsage.DigitalSignature | KeyUsage.KeyEncipherment); + selection = RecipientSelector.Select(new[] { cert }); + + Assert.True(selection.CanEnvelope); + Assert.Equal(RecipientKind.KeyTransport, selection.EncryptionKind); + Assert.Same(cert, selection.EncryptionCertificate); + Assert.Same(cert, selection.SigningCertificate); + Assert.DoesNotContain(selection.Findings, f => f.Code == "no-encryption-cert"); + } + + [Fact] + public void Single_dual_use_ec_uses_key_agreement() { + X509Certificate2 cert; + RecipientSelection selection; + + cert = TestCertFactory.Make("ec", KeyUsage.DigitalSignature | KeyUsage.KeyAgreement); + selection = RecipientSelector.Select(new[] { cert }); + + Assert.True(selection.CanEnvelope); + Assert.Equal(RecipientKind.KeyAgreement, selection.EncryptionKind); + } + + [Fact] + public void Signature_only_ca_cannot_envelope() { + X509Certificate2 cert; + RecipientSelection selection; + + cert = TestCertFactory.Make("ml-dsa", KeyUsage.DigitalSignature | KeyUsage.KeyCertSign); + selection = RecipientSelector.Select(new[] { cert }); + + Assert.False(selection.CanEnvelope); + Assert.Null(selection.EncryptionCertificate); + Assert.Contains(selection.Findings, f => f.Code == "no-encryption-cert"); + } + + [Fact] + public void Split_mldsa_sign_rsa_encrypt() { + X509Certificate2 sign; + X509Certificate2 enc; + RecipientSelection selection; + + sign = TestCertFactory.Make("ml-dsa", KeyUsage.DigitalSignature | KeyUsage.KeyCertSign); + enc = TestCertFactory.Make("rsa", KeyUsage.KeyEncipherment); + selection = RecipientSelector.Select(new[] { sign, enc }); + + Assert.True(selection.CanEnvelope); + Assert.Same(enc, selection.EncryptionCertificate); + Assert.Same(sign, selection.SigningCertificate); + Assert.Equal(RecipientKind.KeyTransport, selection.EncryptionKind); + } + + [Fact] + public void Split_mldsa_sign_mlkem_encrypt() { + X509Certificate2 sign; + X509Certificate2 enc; + RecipientSelection selection; + + sign = TestCertFactory.Make("ml-dsa", KeyUsage.DigitalSignature | KeyUsage.KeyCertSign); + enc = TestCertFactory.Make("ml-kem", KeyUsage.KeyEncipherment); + selection = RecipientSelector.Select(new[] { sign, enc }); + + Assert.True(selection.CanEnvelope); + Assert.Equal(RecipientKind.Kem, selection.EncryptionKind); + Assert.Same(enc, selection.EncryptionCertificate); + } + + [Fact] + public void Encryption_capable_key_without_keyusage_bit_is_flagged() { + X509Certificate2 sign; + X509Certificate2 enc; + RecipientSelection selection; + + // RSA key, but the operator forgot to set keyEncipherment (only digitalSignature). + sign = TestCertFactory.Make("ml-dsa", KeyUsage.DigitalSignature | KeyUsage.KeyCertSign); + enc = TestCertFactory.Make("rsa", KeyUsage.DigitalSignature); + selection = RecipientSelector.Select(new[] { sign, enc }); + + Assert.False(selection.CanEnvelope); + Assert.Contains(selection.Findings, f => f.Code == "encryption-keyusage-missing"); + } + + [Fact] + public void Positional_strategy_picks_second_cert() { + X509Certificate2 sign; + X509Certificate2 enc; + RecipientSelection selection; + + sign = TestCertFactory.Make("ec", KeyUsage.DigitalSignature); + enc = TestCertFactory.Make("rsa", KeyUsage.KeyEncipherment); + selection = RecipientSelector.Select(new[] { sign, enc }, RecipientStrategy.Positional); + + Assert.Same(enc, selection.EncryptionCertificate); + Assert.Equal(RecipientKind.KeyTransport, selection.EncryptionKind); + } +} From 1804fe4b330136f077169ce06ef859a2bcef396f Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 16:16:20 -0600 Subject: [PATCH 03/13] Recipient-aware: factor BcEnvelope.Build, branch EnvelopedData by recipient algorithm (RSA; EC/ML-KEM unsupported -> clean error) --- .../BcEnvelope.cs | 43 ++++++++++++ .../BcPkiMessage.cs | 12 +--- .../RecipientEnvelopeTests.cs | 65 +++++++++++++++++++ 3 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs create mode 100644 tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs new file mode 100644 index 0000000..f5eba3b --- /dev/null +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs @@ -0,0 +1,43 @@ +using System; +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Cms; +using Org.BouncyCastle.Security; + +namespace ScepTestClient.Crypto.BouncyCastle; + +// Builds the SCEP inner CMS EnvelopedData, choosing the RecipientInfo by the recipient certificate's +// key algorithm. This is the single place recipient-algorithm branching lives in the provider; an +// intermediate ASN.1 layer (design spec §3.5) could later own this, but for now it stays internal. +internal static class BcEnvelope { + private const string OidRsa = "1.2.840.113549.1.1.1"; + private const string OidEc = "1.2.840.10045.2.1"; + private const string OidMlKemArc = "2.16.840.1.101.3.4.4."; + + public static byte[] Build(X509Certificate2 recipient_cert, byte[] content_der, string content_encryption_oid, SecureRandom random) { + string algorithm_oid; + Org.BouncyCastle.X509.X509Certificate bc_cert; + CmsEnvelopedDataGenerator generator; + CmsEnvelopedData enveloped; + + algorithm_oid = recipient_cert.GetKeyAlgorithm(); + generator = new CmsEnvelopedDataGenerator(random); + + if (algorithm_oid == OidRsa) { + bc_cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(recipient_cert.RawData); + generator.AddKeyTransRecipient(bc_cert); + } else if (algorithm_oid == OidEc) { + // EC key-agreement (KeyAgreeRecipientInfo / ephemeral-static ECDH) is a clean drop-in here + // — provider-internal, no contract change — but not implemented yet. + throw new NotSupportedException("EC key-agreement recipients are not implemented by this provider"); + } else if (algorithm_oid.StartsWith(OidMlKemArc, StringComparison.Ordinal)) { + // ML-KEM (RFC 9629 KEMRecipientInfo) — BouncyCastle 2.5.0 has no CMS KEM recipient generator; + // would be a hand-rolled drop-in here, or supplied by an external provider. + throw new NotSupportedException("ML-KEM (KEMRecipientInfo) recipients are not implemented by this provider"); + } else { + throw new NotSupportedException($"recipient key algorithm '{algorithm_oid}' cannot be used to encrypt a SCEP request"); + } + + enveloped = generator.Generate(new CmsProcessableByteArray(content_der), content_encryption_oid); + return enveloped.GetEncoded(); + } +} diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs index 668e376..4e65eb0 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs @@ -16,9 +16,8 @@ internal static class BcPkiMessage { private const string SigningTimeOid = "1.2.840.113549.1.9.5"; public static byte[] EncodePkiOperation(PkiMessage message, byte[] inner_payload_der, BcKey signer_key, string message_type_number, FaultDirectives? faults) { - CmsEnvelopedDataGenerator enveloped_gen; - CmsEnvelopedData enveloped; CmsProcessable enveloped_content; + byte[] enveloped_bytes; Org.BouncyCastle.X509.X509Certificate signer_cert; CmsSignedDataGenerator signed_gen; Dictionary signed_attrs; @@ -27,12 +26,9 @@ public static byte[] EncodePkiOperation(PkiMessage message, byte[] inner_payload IStore cert_store; string trans_id; byte[] sender_nonce; - Org.BouncyCastle.X509.X509Certificate recipient_bc_cert; byte[] payload_for_envelope; Org.BouncyCastle.Crypto.AsymmetricKeyParameter signing_private_key; - recipient_bc_cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(message.RecipientCaCert!.RawData); - if (message.SignerCert != null) { signer_cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(message.SignerCert.RawData); } else { @@ -80,16 +76,14 @@ public static byte[] EncodePkiOperation(PkiMessage message, byte[] inner_payload } // ---- END deliberate-fault branch ---- - enveloped_gen = new CmsEnvelopedDataGenerator(Random); - enveloped_gen.AddKeyTransRecipient(recipient_bc_cert); - enveloped = enveloped_gen.Generate(new CmsProcessableByteArray(payload_for_envelope), message.ContentEncryptionAlgorithmOid); + enveloped_bytes = BcEnvelope.Build(message.RecipientCaCert!, payload_for_envelope, message.ContentEncryptionAlgorithmOid, Random); cert_store = CollectionUtilities.CreateStore(new[] { signer_cert }); signed_gen = new CmsSignedDataGenerator(Random); signed_gen.AddSigner(signing_private_key, signer_cert, message.DigestAlgorithmOid, signed_attr_table, null); signed_gen.AddCertificates(cert_store); - enveloped_content = new CmsProcessableByteArray(enveloped.GetEncoded()); + enveloped_content = new CmsProcessableByteArray(enveloped_bytes); signed_data = signed_gen.Generate(Org.BouncyCastle.Asn1.Cms.CmsObjectIdentifiers.EnvelopedData.Id, enveloped_content, true); return signed_data.GetEncoded(); diff --git a/tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs b/tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs new file mode 100644 index 0000000..aebcd36 --- /dev/null +++ b/tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Asn1.X509; +using ScepTestClient.Core; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using ScepTestClient.Tests.Fakes; +using Xunit; + +namespace ScepTestClient.Tests; + +// The provider builds the EnvelopedData via BcEnvelope, branching by the recipient cert's algorithm. +// RSA key-transport is exercised end-to-end elsewhere; here we assert the unsupported recipient kinds +// fail cleanly (no throw) with a recognizable message that Core can turn into a finding. +public sealed class RecipientEnvelopeTests { + [Theory] + [InlineData("ml-kem", "ML-KEM")] + [InlineData("ec", "EC")] + public void Unsupported_recipient_fails_cleanly(string algorithm, string expected_in_error) { + BouncyCastleScepCrypto crypto; + X509Certificate2 recipient; + ScepRequestBuilder builder; + PkiMessage message; + IScepKey subject_key; + string error; + byte[] der; + bool ok; + + crypto = new BouncyCastleScepCrypto(); + recipient = TestCertFactory.Make(algorithm, KeyUsage.KeyEncipherment); + builder = ScepRequestBuilder.For(crypto) + .CaCertificate(recipient) + .MessageType(ScepTestClient.CryptoApi.MessageType.PkcsReq) + .Subject("CN=recip-test") + .KeySpec("rsa:2048"); + Assert.True(builder.Build(out message, out subject_key, out error), error); + + ok = message.Encode(crypto, out der, out error); + + Assert.False(ok); + Assert.Contains(expected_in_error, error); + } + + [Fact] + public void Rsa_recipient_still_envelopes() { + BouncyCastleScepCrypto crypto; + X509Certificate2 recipient; + ScepRequestBuilder builder; + PkiMessage message; + IScepKey subject_key; + string error; + byte[] der; + + crypto = new BouncyCastleScepCrypto(); + recipient = TestCertFactory.Make("rsa", KeyUsage.KeyEncipherment); + builder = ScepRequestBuilder.For(crypto) + .CaCertificate(recipient) + .MessageType(ScepTestClient.CryptoApi.MessageType.PkcsReq) + .Subject("CN=recip-test") + .KeySpec("rsa:2048"); + Assert.True(builder.Build(out message, out subject_key, out error), error); + + Assert.True(message.Encode(crypto, out der, out error), error); + Assert.True(der.Length > 0); + } +} From 88c3f64f19647181582ce336090e4b64878ce9c5 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 16:25:03 -0600 Subject: [PATCH 04/13] Recipient-aware: wire RecipientSelector into the enroll path (select encryption cert by KeyUsage; finding when none) --- src/ScepTestClient.Core/ScepClient.cs | 36 +++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/ScepTestClient.Core/ScepClient.cs b/src/ScepTestClient.Core/ScepClient.cs index 8e19a2e..caf62c1 100644 --- a/src/ScepTestClient.Core/ScepClient.cs +++ b/src/ScepTestClient.Core/ScepClient.cs @@ -6,6 +6,7 @@ using System.Text; using System.Threading.Tasks; using ScepTestClient.Core.Protocol; +using ScepTestClient.Core.Recipients; using ScepTestClient.Core.Transport; using ScepTestClient.CryptoApi; @@ -598,7 +599,10 @@ public async Task> GetNewCertificateAsync(EnrollReques if (!ca_result.IsOk) { return ScepResult.Fail(ca_result.Status, ca_result.Error); } - request.CaCertificate = ca_result.Value[0]; + if (!SelectRecipient(ca_result.Value, out X509Certificate2 recipient, out string select_error)) { + return ScepResult.Fail(ScepClientResult.ProtocolError, select_error); + } + request.CaCertificate = recipient; } enroll_result = await EnrollAsync(request).ConfigureAwait(false); @@ -635,7 +639,10 @@ public ScepResult GetNewCertificate(EnrollRequest request, Storag if (!ca_result.IsOk) { return ScepResult.Fail(ca_result.Status, ca_result.Error); } - request.CaCertificate = ca_result.Value[0]; + if (!SelectRecipient(ca_result.Value, out X509Certificate2 recipient, out string select_error)) { + return ScepResult.Fail(ScepClientResult.ProtocolError, select_error); + } + request.CaCertificate = recipient; } enroll_result = Enroll(request); @@ -663,6 +670,31 @@ public ScepResult GetNewCertificate(EnrollRequest request, Storag // Private helpers // ------------------------------------------------------------------------- + // Choose the EnvelopedData recipient from the GetCACert bundle by KeyUsage (SCEP allows separate + // signing and encryption certs). Emits conformance findings; fails (no send) when the server + // offers no encryption-capable recipient. + private bool SelectRecipient(IReadOnlyList certs, out X509Certificate2 recipient, out string error) { + RecipientSelection selection; + + recipient = null!; + error = string.Empty; + + selection = RecipientSelector.Select(certs); + foreach (RecipientFinding finding in selection.Findings) { + Emit(TraceLevel.Opinion, "RecipientSelection", $"{finding.Code}: {finding.Message}"); + } + + if (!selection.CanEnvelope || selection.EncryptionCertificate is null) { + error = selection.Findings.Count > 0 + ? selection.Findings[0].Message + : "GetCACert returned no encryption-capable recipient certificate"; + return false; + } + + recipient = selection.EncryptionCertificate; + return true; + } + private ScepResult BuildPkiMessage(EnrollRequest request, out PkiMessage pki_message, out string error) { Pkcs10 csr; From 85c17cd746f07600d49e0aa4fb04da7ef3071ea8 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 16:25:03 -0600 Subject: [PATCH 05/13] Recipient-aware: fake server split signing/encryption bundle + end-to-end enroll/conformance tests --- .../Fakes/FakeScepServer.cs | 13 ++- tests/ScepTestClient.Tests/Fakes/TestCa.cs | 95 ++++++++++++++++++- .../SplitCertEnrollTests.cs | 79 +++++++++++++++ 3 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 tests/ScepTestClient.Tests/SplitCertEnrollTests.cs diff --git a/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs b/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs index fb30281..d6d4f3a 100644 --- a/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs +++ b/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs @@ -17,13 +17,15 @@ public sealed class FakeScepServer : IAsyncDisposable { private FakeScepServer(WebApplication app, TestCa ca) { _app = app; Ca = ca; ScepUrl = new Uri("http://127.0.0.1/scep"); } - public static async Task StartAsync() { + public static async Task StartAsync() => await StartAsync(null); + + public static async Task StartAsync(TestCa? ca_override) { WebApplicationBuilder builder; WebApplication app; TestCa ca; FakeScepServer self; - ca = TestCa.Create(); + ca = ca_override ?? TestCa.Create(); builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); @@ -36,8 +38,11 @@ public static async Task StartAsync() { op = ctx.Request.Query["operation"].ToString(); if (op == "GetCACaps") { await ctx.Response.WriteAsync(self.CaCapsBody); return; } if (op == "GetCACert") { - ctx.Response.ContentType = "application/x-x509-ca-cert"; - await ctx.Response.Body.WriteAsync(ca.Certificate.GetEncoded()); + byte[] bundle; + + bundle = ca.GetCaCertBundleDer(); + ctx.Response.ContentType = ca.EncryptionCert is null ? "application/x-x509-ca-cert" : "application/x-x509-ca-ra-cert"; + await ctx.Response.Body.WriteAsync(bundle); return; } ctx.Response.StatusCode = 400; diff --git a/tests/ScepTestClient.Tests/Fakes/TestCa.cs b/tests/ScepTestClient.Tests/Fakes/TestCa.cs index 1c332d1..debaaae 100644 --- a/tests/ScepTestClient.Tests/Fakes/TestCa.cs +++ b/tests/ScepTestClient.Tests/Fakes/TestCa.cs @@ -29,6 +29,12 @@ public sealed class TestCa { public bool PendingMode { get; set; } public string? ExpectedChallenge { get; set; } + // Optional separate RA encryption cert/key (SCEP split signing vs. encryption certs). When set, + // GetCACert presents a [signing, encryption] bundle and requests are decrypted with this key. + public X509Certificate2? EncryptionCert { get; private set; } + private AsymmetricCipherKeyPair? _encryption_key; + private AsymmetricKeyParameter RecipientKey => _encryption_key?.Private ?? KeyPair.Private; + private TestCa(AsymmetricCipherKeyPair keyPair, Org.BouncyCastle.X509.X509Certificate cert) { KeyPair = keyPair; Certificate = cert; @@ -57,6 +63,91 @@ public static TestCa Create() { return new TestCa(pair, cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", pair.Private))); } + // Builds a CA with a SEPARATE RA encryption certificate (RSA): the CA/signing cert carries + // digitalSignature+keyCertSign (no keyEncipherment), the RA cert carries keyEncipherment. + // GetCACert then presents both, and requests must be encrypted to the RA cert. + public static TestCa CreateWithRaEncryption() { + RsaKeyPairGenerator ca_gen; + AsymmetricCipherKeyPair ca_pair; + X509Name ca_name; + X509V3CertificateGenerator ca_cg; + TestCa ca; + RsaKeyPairGenerator ra_gen; + AsymmetricCipherKeyPair ra_pair; + X509V3CertificateGenerator ra_cg; + + ca_gen = new RsaKeyPairGenerator(); + ca_gen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + ca_pair = ca_gen.GenerateKeyPair(); + ca_name = new X509Name("CN=Test SCEP CA (split)"); + ca_cg = new X509V3CertificateGenerator(); + ca_cg.SetSerialNumber(BigInteger.One); + ca_cg.SetIssuerDN(ca_name); + ca_cg.SetSubjectDN(ca_name); + ca_cg.SetNotBefore(DateTime.UtcNow.AddDays(-1)); + ca_cg.SetNotAfter(DateTime.UtcNow.AddYears(5)); + ca_cg.SetPublicKey(ca_pair.Public); + ca_cg.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(KeyUsage.DigitalSignature | KeyUsage.KeyCertSign)); + ca = new TestCa(ca_pair, ca_cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", ca_pair.Private))); + + ra_gen = new RsaKeyPairGenerator(); + ra_gen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + ra_pair = ra_gen.GenerateKeyPair(); + ra_cg = new X509V3CertificateGenerator(); + ra_cg.SetSerialNumber(BigInteger.ValueOf(2)); + ra_cg.SetIssuerDN(ca_name); + ra_cg.SetSubjectDN(new X509Name("CN=Test RA Encryption")); + ra_cg.SetNotBefore(DateTime.UtcNow.AddDays(-1)); + ra_cg.SetNotAfter(DateTime.UtcNow.AddYears(5)); + ra_cg.SetPublicKey(ra_pair.Public); + ra_cg.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(KeyUsage.KeyEncipherment)); + + ca._encryption_key = ra_pair; + ca.EncryptionCert = new X509Certificate2(ra_cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", ca_pair.Private)).GetEncoded()); + return ca; + } + + // A single CA cert whose KeyUsage is signature-only (digitalSignature+keyCertSign, NO + // keyEncipherment) — models a server that cannot receive an encrypted SCEP request. + public static TestCa CreateSigningOnly() { + RsaKeyPairGenerator gen; + AsymmetricCipherKeyPair pair; + X509Name name; + X509V3CertificateGenerator cg; + + gen = new RsaKeyPairGenerator(); + gen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + pair = gen.GenerateKeyPair(); + name = new X509Name("CN=Test SCEP CA (signing only)"); + cg = new X509V3CertificateGenerator(); + cg.SetSerialNumber(BigInteger.One); + cg.SetIssuerDN(name); + cg.SetSubjectDN(name); + cg.SetNotBefore(DateTime.UtcNow.AddDays(-1)); + cg.SetNotAfter(DateTime.UtcNow.AddYears(5)); + cg.SetPublicKey(pair.Public); + cg.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(KeyUsage.DigitalSignature | KeyUsage.KeyCertSign)); + return new TestCa(pair, cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", pair.Private))); + } + + // GetCACert response: a single cert by default, or a degenerate PKCS#7 [signing, encryption] + // bundle when a separate RA encryption cert is configured. + public byte[] GetCaCertBundleDer() { + Org.BouncyCastle.X509.X509Certificate enc_bc; + IStore store; + CmsSignedDataGenerator gen; + + if (EncryptionCert is null) { + return Certificate.GetEncoded(); + } + + enc_bc = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(EncryptionCert.RawData); + store = CollectionUtilities.CreateStore(new[] { Certificate, enc_bc }); + gen = new CmsSignedDataGenerator(); + gen.AddCertificates(store); + return gen.Generate(new CmsProcessableByteArray(System.Array.Empty()), false).GetEncoded(); + } + public Org.BouncyCastle.X509.X509Certificate Issue(AsymmetricKeyParameter subject_public_key, string subject_dn) { X509V3CertificateGenerator cg; @@ -388,7 +479,7 @@ private byte[] DecryptInner(byte[] der) { env_stream = new MemoryStream(); signed.SignedContent.Write(env_stream); env = new CmsEnvelopedData(env_stream.ToArray()); - return env.GetRecipientInfos().GetRecipients().Cast().First().GetContent(KeyPair.Private); + return env.GetRecipientInfos().GetRecipients().Cast().First().GetContent(RecipientKey); } public string PeekMessageType(byte[] der) { @@ -449,7 +540,7 @@ private void DecodeRequest(byte[] der, out X509Certificate2 requester_cert, out env_stream = new MemoryStream(); signed.SignedContent.Write(env_stream); env = new CmsEnvelopedData(env_stream.ToArray()); - inner_payload = env.GetRecipientInfos().GetRecipients().Cast().First().GetContent(KeyPair.Private); + inner_payload = env.GetRecipientInfos().GetRecipients().Cast().First().GetContent(RecipientKey); trans_id = "tx"; sender_nonce = new byte[16]; diff --git a/tests/ScepTestClient.Tests/SplitCertEnrollTests.cs b/tests/ScepTestClient.Tests/SplitCertEnrollTests.cs new file mode 100644 index 0000000..7da019f --- /dev/null +++ b/tests/ScepTestClient.Tests/SplitCertEnrollTests.cs @@ -0,0 +1,79 @@ +using System.IO; +using System.Threading.Tasks; +using ScepTestClient.Core; +using ScepTestClient.Core.Storage; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using ScepTestClient.Tests.Fakes; +using Xunit; + +namespace ScepTestClient.Tests; + +// End-to-end: the client must parse the GetCACert bundle, select the encryption cert by KeyUsage, +// and envelope the request to it (not blindly to the first / CA signing cert). +public sealed class SplitCertEnrollTests { + [Fact] + public async Task Enroll_through_separate_ra_encryption_cert_succeeds() { + FakeScepServer server; + BouncyCastleScepCrypto crypto; + ScepClient client; + string root; + KeySpec spec; + IScepKey key; + string error; + EnrollRequest request; + ScepResult outcome; + + server = await FakeScepServer.StartAsync(TestCa.CreateWithRaEncryption()); + try { + crypto = new BouncyCastleScepCrypto(); + ScepClient.Create(new ServerConfig { Id = "split", Url = server.ScepUrl, PreferPost = true }, crypto, handler: null, out client, out _); + root = Directory.CreateTempSubdirectory().FullName; + + Assert.True(KeySpec.Parse("rsa:2048", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + + request = new EnrollRequest { Subject = "CN=split-enroll", Key = key }; + outcome = client.GetNewCertificate(request, new CertStore(root), new UseRecordLog(root)); + + // Succeeds only if the client enveloped to the RA encryption cert: the CA/signing cert + // lacks keyEncipherment, and the server decrypts with the separate RA key. + Assert.True(outcome.IsOk, $"{outcome.Status} {outcome.Error}"); + Assert.NotNull(outcome.Value.Certificate); + } finally { + await server.DisposeAsync(); + } + } + + [Fact] + public async Task Enroll_against_signing_only_ca_fails_with_conformance_finding() { + FakeScepServer server; + BouncyCastleScepCrypto crypto; + ScepClient client; + string root; + KeySpec spec; + IScepKey key; + string error; + EnrollRequest request; + ScepResult outcome; + + server = await FakeScepServer.StartAsync(TestCa.CreateSigningOnly()); + try { + crypto = new BouncyCastleScepCrypto(); + ScepClient.Create(new ServerConfig { Id = "signonly", Url = server.ScepUrl, PreferPost = true }, crypto, handler: null, out client, out _); + root = Directory.CreateTempSubdirectory().FullName; + + Assert.True(KeySpec.Parse("rsa:2048", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + + request = new EnrollRequest { Subject = "CN=signonly-enroll", Key = key }; + outcome = client.GetNewCertificate(request, new CertStore(root), new UseRecordLog(root)); + + // The server's only cert lacks keyEncipherment, so the request cannot be enveloped. + Assert.False(outcome.IsOk); + Assert.Contains("envelop", outcome.Error); + } finally { + await server.DisposeAsync(); + } + } +} From 8ceabe48acc4a23f6326950e5d7e8b5fdf61b40a Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 16:33:53 -0600 Subject: [PATCH 06/13] Recipient-aware: implement EC key-agreement (ephemeral ECDH) enveloping + EC RA cert round-trip test --- .../BcEnvelope.cs | 21 +++++++-- tests/ScepTestClient.Tests/Fakes/TestCa.cs | 46 ++++++++++++++++--- .../RecipientEnvelopeTests.cs | 12 ++--- .../SplitCertEnrollTests.cs | 33 +++++++++++++ 4 files changed, 97 insertions(+), 15 deletions(-) diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs index f5eba3b..30f3b3d 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs @@ -26,9 +26,24 @@ public static byte[] Build(X509Certificate2 recipient_cert, byte[] content_der, bc_cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(recipient_cert.RawData); generator.AddKeyTransRecipient(bc_cert); } else if (algorithm_oid == OidEc) { - // EC key-agreement (KeyAgreeRecipientInfo / ephemeral-static ECDH) is a clean drop-in here - // — provider-internal, no contract change — but not implemented yet. - throw new NotSupportedException("EC key-agreement recipients are not implemented by this provider"); + Org.BouncyCastle.Crypto.Parameters.ECPublicKeyParameters recipient_public; + Org.BouncyCastle.Crypto.Generators.ECKeyPairGenerator ec_generator; + Org.BouncyCastle.Crypto.AsymmetricCipherKeyPair ephemeral; + + // EC key-agreement (KeyAgreeRecipientInfo): ephemeral-static ECDH. The ephemeral originator + // key is generated on the recipient's curve, used to derive the KEK, then discarded — it is + // a transient internal to this operation, never an IScepKey and never on the contract. + bc_cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(recipient_cert.RawData); + recipient_public = (Org.BouncyCastle.Crypto.Parameters.ECPublicKeyParameters)bc_cert.GetPublicKey(); + ec_generator = new Org.BouncyCastle.Crypto.Generators.ECKeyPairGenerator("ECDH"); + ec_generator.Init(new Org.BouncyCastle.Crypto.Parameters.ECKeyGenerationParameters(recipient_public.Parameters, random)); + ephemeral = ec_generator.GenerateKeyPair(); + generator.AddKeyAgreementRecipient( + CmsEnvelopedGenerator.ECDHSha256Kdf, + ephemeral.Private, + ephemeral.Public, + bc_cert, + CmsEnvelopedGenerator.Aes128Wrap); } else if (algorithm_oid.StartsWith(OidMlKemArc, StringComparison.Ordinal)) { // ML-KEM (RFC 9629 KEMRecipientInfo) — BouncyCastle 2.5.0 has no CMS KEM recipient generator; // would be a hand-rolled drop-in here, or supplied by an external provider. diff --git a/tests/ScepTestClient.Tests/Fakes/TestCa.cs b/tests/ScepTestClient.Tests/Fakes/TestCa.cs index debaaae..cec4ebb 100644 --- a/tests/ScepTestClient.Tests/Fakes/TestCa.cs +++ b/tests/ScepTestClient.Tests/Fakes/TestCa.cs @@ -66,14 +66,14 @@ public static TestCa Create() { // Builds a CA with a SEPARATE RA encryption certificate (RSA): the CA/signing cert carries // digitalSignature+keyCertSign (no keyEncipherment), the RA cert carries keyEncipherment. // GetCACert then presents both, and requests must be encrypted to the RA cert. - public static TestCa CreateWithRaEncryption() { + public static TestCa CreateWithRaEncryption(string enc_algo = "rsa") { RsaKeyPairGenerator ca_gen; AsymmetricCipherKeyPair ca_pair; X509Name ca_name; X509V3CertificateGenerator ca_cg; TestCa ca; - RsaKeyPairGenerator ra_gen; AsymmetricCipherKeyPair ra_pair; + int ra_key_usage; X509V3CertificateGenerator ra_cg; ca_gen = new RsaKeyPairGenerator(); @@ -90,9 +90,7 @@ public static TestCa CreateWithRaEncryption() { ca_cg.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(KeyUsage.DigitalSignature | KeyUsage.KeyCertSign)); ca = new TestCa(ca_pair, ca_cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", ca_pair.Private))); - ra_gen = new RsaKeyPairGenerator(); - ra_gen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); - ra_pair = ra_gen.GenerateKeyPair(); + ra_pair = GenerateRaKeyPair(enc_algo, out ra_key_usage); ra_cg = new X509V3CertificateGenerator(); ra_cg.SetSerialNumber(BigInteger.ValueOf(2)); ra_cg.SetIssuerDN(ca_name); @@ -100,13 +98,49 @@ public static TestCa CreateWithRaEncryption() { ra_cg.SetNotBefore(DateTime.UtcNow.AddDays(-1)); ra_cg.SetNotAfter(DateTime.UtcNow.AddYears(5)); ra_cg.SetPublicKey(ra_pair.Public); - ra_cg.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(KeyUsage.KeyEncipherment)); + ra_cg.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(ra_key_usage)); ca._encryption_key = ra_pair; ca.EncryptionCert = new X509Certificate2(ra_cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", ca_pair.Private)).GetEncoded()); return ca; } + // Generates the RA encryption keypair for the chosen algorithm and reports the matching KeyUsage: + // RSA/ML-KEM -> keyEncipherment, EC -> keyAgreement. + private static AsymmetricCipherKeyPair GenerateRaKeyPair(string enc_algo, out int key_usage) { + SecureRandom random; + + random = new SecureRandom(); + switch (enc_algo.ToLowerInvariant()) { + case "rsa": { + RsaKeyPairGenerator rsa_gen; + + rsa_gen = new RsaKeyPairGenerator(); + rsa_gen.Init(new KeyGenerationParameters(random, 2048)); + key_usage = KeyUsage.KeyEncipherment; + return rsa_gen.GenerateKeyPair(); + } + case "ec": { + Org.BouncyCastle.Crypto.Generators.ECKeyPairGenerator ec_gen; + + ec_gen = new Org.BouncyCastle.Crypto.Generators.ECKeyPairGenerator("ECDH"); + ec_gen.Init(new Org.BouncyCastle.Crypto.Parameters.ECKeyGenerationParameters(Org.BouncyCastle.Asn1.Sec.SecObjectIdentifiers.SecP256r1, random)); + key_usage = KeyUsage.KeyAgreement; + return ec_gen.GenerateKeyPair(); + } + case "ml-kem": { + Org.BouncyCastle.Crypto.Generators.MLKemKeyPairGenerator mlkem_gen; + + mlkem_gen = new Org.BouncyCastle.Crypto.Generators.MLKemKeyPairGenerator(); + mlkem_gen.Init(new Org.BouncyCastle.Crypto.Parameters.MLKemKeyGenerationParameters(random, Org.BouncyCastle.Crypto.Parameters.MLKemParameters.ml_kem_768)); + key_usage = KeyUsage.KeyEncipherment; + return mlkem_gen.GenerateKeyPair(); + } + default: + throw new ArgumentException($"unsupported RA encryption algorithm '{enc_algo}'"); + } + } + // A single CA cert whose KeyUsage is signature-only (digitalSignature+keyCertSign, NO // keyEncipherment) — models a server that cannot receive an encrypted SCEP request. public static TestCa CreateSigningOnly() { diff --git a/tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs b/tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs index aebcd36..22645da 100644 --- a/tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs +++ b/tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs @@ -12,10 +12,10 @@ namespace ScepTestClient.Tests; // RSA key-transport is exercised end-to-end elsewhere; here we assert the unsupported recipient kinds // fail cleanly (no throw) with a recognizable message that Core can turn into a finding. public sealed class RecipientEnvelopeTests { - [Theory] - [InlineData("ml-kem", "ML-KEM")] - [InlineData("ec", "EC")] - public void Unsupported_recipient_fails_cleanly(string algorithm, string expected_in_error) { + // ML-KEM has no CMS recipient generator in BouncyCastle 2.5.0, so it must fail cleanly (no throw) + // with a recognizable message. (EC and RSA recipients ARE supported — see the round-trip tests.) + [Fact] + public void Mlkem_recipient_fails_cleanly() { BouncyCastleScepCrypto crypto; X509Certificate2 recipient; ScepRequestBuilder builder; @@ -26,7 +26,7 @@ public void Unsupported_recipient_fails_cleanly(string algorithm, string expecte bool ok; crypto = new BouncyCastleScepCrypto(); - recipient = TestCertFactory.Make(algorithm, KeyUsage.KeyEncipherment); + recipient = TestCertFactory.Make("ml-kem", KeyUsage.KeyEncipherment); builder = ScepRequestBuilder.For(crypto) .CaCertificate(recipient) .MessageType(ScepTestClient.CryptoApi.MessageType.PkcsReq) @@ -37,7 +37,7 @@ public void Unsupported_recipient_fails_cleanly(string algorithm, string expecte ok = message.Encode(crypto, out der, out error); Assert.False(ok); - Assert.Contains(expected_in_error, error); + Assert.Contains("ML-KEM", error); } [Fact] diff --git a/tests/ScepTestClient.Tests/SplitCertEnrollTests.cs b/tests/ScepTestClient.Tests/SplitCertEnrollTests.cs index 7da019f..92ffd60 100644 --- a/tests/ScepTestClient.Tests/SplitCertEnrollTests.cs +++ b/tests/ScepTestClient.Tests/SplitCertEnrollTests.cs @@ -76,4 +76,37 @@ public async Task Enroll_against_signing_only_ca_fails_with_conformance_finding( await server.DisposeAsync(); } } + + [Fact] + public async Task Enroll_through_ec_key_agreement_ra_cert_succeeds() { + FakeScepServer server; + BouncyCastleScepCrypto crypto; + ScepClient client; + string root; + KeySpec spec; + IScepKey key; + string error; + EnrollRequest request; + ScepResult outcome; + + server = await FakeScepServer.StartAsync(TestCa.CreateWithRaEncryption("ec")); + try { + crypto = new BouncyCastleScepCrypto(); + ScepClient.Create(new ServerConfig { Id = "ec", Url = server.ScepUrl, PreferPost = true }, crypto, handler: null, out client, out _); + root = Directory.CreateTempSubdirectory().FullName; + + Assert.True(KeySpec.Parse("rsa:2048", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + + request = new EnrollRequest { Subject = "CN=ec-enroll", Key = key }; + outcome = client.GetNewCertificate(request, new CertStore(root), new UseRecordLog(root)); + + // Succeeds only if the client enveloped to the EC RA cert via ECDH key agreement and the + // server decrypted with the EC private key. + Assert.True(outcome.IsOk, $"{outcome.Status} {outcome.Error}"); + Assert.NotNull(outcome.Value.Certificate); + } finally { + await server.DisposeAsync(); + } + } } From 87246b703d944d734f02f4aa7c24dab4dec41b2a Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 16:37:36 -0600 Subject: [PATCH 07/13] Recipient-aware: ready-to-use per-profile fake-server endpoints (/scep/) + matrix tests --- .../EndpointMatrixTests.cs | 68 ++++++++++ .../Fakes/FakeScepServer.cs | 116 ++++++++++++------ 2 files changed, 145 insertions(+), 39 deletions(-) create mode 100644 tests/ScepTestClient.Tests/EndpointMatrixTests.cs diff --git a/tests/ScepTestClient.Tests/EndpointMatrixTests.cs b/tests/ScepTestClient.Tests/EndpointMatrixTests.cs new file mode 100644 index 0000000..ae0e47f --- /dev/null +++ b/tests/ScepTestClient.Tests/EndpointMatrixTests.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using ScepTestClient.Core; +using ScepTestClient.Core.Storage; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using ScepTestClient.Tests.Fakes; +using Xunit; + +namespace ScepTestClient.Tests; + +// Drives the ready-to-go per-profile endpoints. A single server start exposes every recipient +// combination; each test just points a client at the matching /scep/ URL. +public sealed class EndpointMatrixTests { + [Theory] + [InlineData("rsa")] + [InlineData("rsa-split")] + [InlineData("ec-encrypt")] + public async Task Enroll_succeeds_against_supported_profile(string profile) { + FakeScepServer server; + ScepResult outcome; + + server = await FakeScepServer.StartAsync(); + try { + outcome = Enroll(server.ProfileUrl(profile), profile); + Assert.True(outcome.IsOk, $"{outcome.Status} {outcome.Error}"); + Assert.NotNull(outcome.Value.Certificate); + } finally { + await server.DisposeAsync(); + } + } + + [Theory] + [InlineData("mlkem-encrypt", "ML-KEM")] + [InlineData("signing-only", "envelop")] + public async Task Enroll_fails_with_finding_against_unsupported_profile(string profile, string expected_in_error) { + FakeScepServer server; + ScepResult outcome; + + server = await FakeScepServer.StartAsync(); + try { + outcome = Enroll(server.ProfileUrl(profile), profile); + Assert.False(outcome.IsOk); + Assert.Contains(expected_in_error, outcome.Error); + } finally { + await server.DisposeAsync(); + } + } + + private static ScepResult Enroll(Uri url, string id) { + BouncyCastleScepCrypto crypto; + ScepClient client; + string root; + KeySpec spec; + IScepKey key; + string error; + EnrollRequest request; + + crypto = new BouncyCastleScepCrypto(); + ScepClient.Create(new ServerConfig { Id = id, Url = url, PreferPost = true }, crypto, handler: null, out client, out _); + root = Directory.CreateTempSubdirectory().FullName; + Assert.True(KeySpec.Parse("rsa:2048", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + request = new EnrollRequest { Subject = $"CN={id}", Key = key }; + return client.GetNewCertificate(request, new CertStore(root), new UseRecordLog(root)); + } +} diff --git a/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs b/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs index d6d4f3a..236cf09 100644 --- a/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs +++ b/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -9,13 +10,26 @@ namespace ScepTestClient.Tests.Fakes; +// A fake SCEP server. Besides the default /scep endpoint it also stands up a set of ready-to-use +// per-profile endpoints at /scep/, each backed by its own TestCa with a specific +// signing/encryption certificate shape — so a real SCEP client (or a scripted test) can exercise +// every recipient combination just by starting the server and hitting the right URL. public sealed class FakeScepServer : IAsyncDisposable { private readonly WebApplication _app; + private readonly Dictionary _profiles; + private string _base_url = "http://127.0.0.1:0"; + public Uri ScepUrl { get; private set; } public TestCa Ca { get; } public string CaCapsBody { get; set; } = "POSTPKIOperation\nSHA-256\nAES\n"; + public IReadOnlyDictionary Profiles => _profiles; - private FakeScepServer(WebApplication app, TestCa ca) { _app = app; Ca = ca; ScepUrl = new Uri("http://127.0.0.1/scep"); } + private FakeScepServer(WebApplication app, TestCa ca, Dictionary profiles) { + _app = app; + Ca = ca; + _profiles = profiles; + ScepUrl = new Uri("http://127.0.0.1/scep"); + } public static async Task StartAsync() => await StartAsync(null); @@ -23,58 +37,82 @@ public static async Task StartAsync(TestCa? ca_override) { WebApplicationBuilder builder; WebApplication app; TestCa ca; + Dictionary profiles; FakeScepServer self; + string base_url; ca = ca_override ?? TestCa.Create(); + profiles = new Dictionary { + { "rsa", TestCa.Create() }, + { "rsa-split", TestCa.CreateWithRaEncryption("rsa") }, + { "ec-encrypt", TestCa.CreateWithRaEncryption("ec") }, + { "mlkem-encrypt", TestCa.CreateWithRaEncryption("ml-kem") }, + { "signing-only", TestCa.CreateSigningOnly() }, + }; + builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); app = builder.Build(); - self = new FakeScepServer(app, ca); - - app.MapGet("/scep", async (HttpContext ctx) => { - string op; - - op = ctx.Request.Query["operation"].ToString(); - if (op == "GetCACaps") { await ctx.Response.WriteAsync(self.CaCapsBody); return; } - if (op == "GetCACert") { - byte[] bundle; - - bundle = ca.GetCaCertBundleDer(); - ctx.Response.ContentType = ca.EncryptionCert is null ? "application/x-x509-ca-cert" : "application/x-x509-ca-ra-cert"; - await ctx.Response.Body.WriteAsync(bundle); - return; - } - ctx.Response.StatusCode = 400; - }); - - app.MapPost("/scep", async (HttpContext ctx) => { - MemoryStream ms; - byte[] request_der; - byte[] response; - string message_type; - - ms = new MemoryStream(); - await ctx.Request.Body.CopyToAsync(ms); - request_der = ms.ToArray(); - message_type = ca.PeekMessageType(request_der); - - if (message_type == "21") { response = ca.HandleGetCert(request_der); } - else if (message_type == "22") { response = ca.HandleGetCrl(request_der); } - else if (message_type == "20") { response = ca.HandlePoll(request_der); } - else { response = ca.HandlePkiOperation(request_der); } - - ctx.Response.ContentType = "application/x-pki-message"; - await ctx.Response.Body.WriteAsync(response); - }); + self = new FakeScepServer(app, ca, profiles); + + // Default endpoint (back-compat): /scep -> the default or caller-supplied CA. + app.MapGet("/scep", (HttpContext ctx) => HandleGet(self.Ca, self.CaCapsBody, ctx)); + app.MapPost("/scep", (HttpContext ctx) => HandlePost(self.Ca, ctx)); + + // One ready-to-go endpoint per profile. + foreach (KeyValuePair profile in profiles) { + TestCa profile_ca; + + profile_ca = profile.Value; + app.MapGet($"/scep/{profile.Key}", (HttpContext ctx) => HandleGet(profile_ca, self.CaCapsBody, ctx)); + app.MapPost($"/scep/{profile.Key}", (HttpContext ctx) => HandlePost(profile_ca, ctx)); + } await app.StartAsync(); - string base_url; base_url = app.Urls.First(); + self._base_url = base_url; self.ScepUrl = new Uri(new Uri(base_url), "/scep"); return self; } + public Uri ProfileUrl(string name) => new Uri(new Uri(_base_url), $"/scep/{name}"); + + private static async Task HandleGet(TestCa ca, string caps_body, HttpContext ctx) { + string op; + byte[] bundle; + + op = ctx.Request.Query["operation"].ToString(); + if (op == "GetCACaps") { await ctx.Response.WriteAsync(caps_body); return; } + if (op == "GetCACert") { + bundle = ca.GetCaCertBundleDer(); + ctx.Response.ContentType = ca.EncryptionCert is null ? "application/x-x509-ca-cert" : "application/x-x509-ca-ra-cert"; + await ctx.Response.Body.WriteAsync(bundle); + return; + } + ctx.Response.StatusCode = 400; + } + + private static async Task HandlePost(TestCa ca, HttpContext ctx) { + MemoryStream ms; + byte[] request_der; + byte[] response; + string message_type; + + ms = new MemoryStream(); + await ctx.Request.Body.CopyToAsync(ms); + request_der = ms.ToArray(); + message_type = ca.PeekMessageType(request_der); + + if (message_type == "21") { response = ca.HandleGetCert(request_der); } + else if (message_type == "22") { response = ca.HandleGetCrl(request_der); } + else if (message_type == "20") { response = ca.HandlePoll(request_der); } + else { response = ca.HandlePkiOperation(request_der); } + + ctx.Response.ContentType = "application/x-pki-message"; + await ctx.Response.Body.WriteAsync(response); + } + public async ValueTask DisposeAsync() => await _app.DisposeAsync(); } From fb0c9b5dd099699f2e33a5ea3aed219c4cd77e2a Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 16:47:07 -0600 Subject: [PATCH 08/13] Recipient-aware: generalize TestCa CA signer (RSA + EC/ECDSA); add ec-dual + ecdsa-rsa profiles --- .../EndpointMatrixTests.cs | 2 + .../Fakes/FakeScepServer.cs | 2 + tests/ScepTestClient.Tests/Fakes/TestCa.cs | 87 ++++++++++++++++--- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/tests/ScepTestClient.Tests/EndpointMatrixTests.cs b/tests/ScepTestClient.Tests/EndpointMatrixTests.cs index ae0e47f..555c810 100644 --- a/tests/ScepTestClient.Tests/EndpointMatrixTests.cs +++ b/tests/ScepTestClient.Tests/EndpointMatrixTests.cs @@ -17,6 +17,8 @@ public sealed class EndpointMatrixTests { [InlineData("rsa")] [InlineData("rsa-split")] [InlineData("ec-encrypt")] + [InlineData("ec-dual")] + [InlineData("ecdsa-rsa")] public async Task Enroll_succeeds_against_supported_profile(string profile) { FakeScepServer server; ScepResult outcome; diff --git a/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs b/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs index 236cf09..93ad638 100644 --- a/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs +++ b/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs @@ -46,6 +46,8 @@ public static async Task StartAsync(TestCa? ca_override) { { "rsa", TestCa.Create() }, { "rsa-split", TestCa.CreateWithRaEncryption("rsa") }, { "ec-encrypt", TestCa.CreateWithRaEncryption("ec") }, + { "ec-dual", TestCa.Create("ec") }, + { "ecdsa-rsa", TestCa.CreateWithRaEncryption("rsa", "ec") }, { "mlkem-encrypt", TestCa.CreateWithRaEncryption("ml-kem") }, { "signing-only", TestCa.CreateSigningOnly() }, }; diff --git a/tests/ScepTestClient.Tests/Fakes/TestCa.cs b/tests/ScepTestClient.Tests/Fakes/TestCa.cs index cec4ebb..6f3f07c 100644 --- a/tests/ScepTestClient.Tests/Fakes/TestCa.cs +++ b/tests/ScepTestClient.Tests/Fakes/TestCa.cs @@ -34,6 +34,7 @@ public sealed class TestCa { public X509Certificate2? EncryptionCert { get; private set; } private AsymmetricCipherKeyPair? _encryption_key; private AsymmetricKeyParameter RecipientKey => _encryption_key?.Private ?? KeyPair.Private; + private string _ca_signature_algorithm = "SHA256WITHRSA"; private TestCa(AsymmetricCipherKeyPair keyPair, Org.BouncyCastle.X509.X509Certificate cert) { KeyPair = keyPair; @@ -63,12 +64,79 @@ public static TestCa Create() { return new TestCa(pair, cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", pair.Private))); } + // A CA whose signing key is the chosen algorithm (rsa/ec/ml-dsa), as a single dual-use cert. + // RSA carries keyEncipherment, EC carries keyAgreement (so it is also the envelope recipient), + // ML-DSA is signature-only (cannot be an encryption recipient). + public static TestCa Create(string ca_algo) { + AsymmetricCipherKeyPair pair; + string sig_alg; + int key_usage; + X509Name name; + X509V3CertificateGenerator cg; + TestCa ca; + + pair = GenerateCaKeyPair(ca_algo, out sig_alg); + key_usage = ca_algo.ToLowerInvariant() switch { + "ec" => KeyUsage.DigitalSignature | KeyUsage.KeyAgreement | KeyUsage.KeyCertSign, + "rsa" => KeyUsage.DigitalSignature | KeyUsage.KeyEncipherment | KeyUsage.KeyCertSign, + _ => KeyUsage.DigitalSignature | KeyUsage.KeyCertSign, + }; + name = new X509Name("CN=Test SCEP CA"); + cg = new X509V3CertificateGenerator(); + cg.SetSerialNumber(BigInteger.One); + cg.SetIssuerDN(name); + cg.SetSubjectDN(name); + cg.SetNotBefore(DateTime.UtcNow.AddDays(-1)); + cg.SetNotAfter(DateTime.UtcNow.AddYears(5)); + cg.SetPublicKey(pair.Public); + cg.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(key_usage)); + ca = new TestCa(pair, cg.Generate(new Asn1SignatureFactory(sig_alg, pair.Private))); + ca._ca_signature_algorithm = sig_alg; + return ca; + } + + // Generates the CA signing keypair for the chosen algorithm and reports the X.509 signature + // algorithm name/OID to use for issuing and CRLs. + private static AsymmetricCipherKeyPair GenerateCaKeyPair(string ca_algo, out string sig_alg) { + SecureRandom random; + + random = new SecureRandom(); + switch (ca_algo.ToLowerInvariant()) { + case "rsa": { + RsaKeyPairGenerator rsa_gen; + + rsa_gen = new RsaKeyPairGenerator(); + rsa_gen.Init(new KeyGenerationParameters(random, 2048)); + sig_alg = "SHA256WITHRSA"; + return rsa_gen.GenerateKeyPair(); + } + case "ec": { + Org.BouncyCastle.Crypto.Generators.ECKeyPairGenerator ec_gen; + + ec_gen = new Org.BouncyCastle.Crypto.Generators.ECKeyPairGenerator("ECDSA"); + ec_gen.Init(new Org.BouncyCastle.Crypto.Parameters.ECKeyGenerationParameters(Org.BouncyCastle.Asn1.Sec.SecObjectIdentifiers.SecP256r1, random)); + sig_alg = "SHA256WITHECDSA"; + return ec_gen.GenerateKeyPair(); + } + case "ml-dsa": { + Org.BouncyCastle.Crypto.Generators.MLDsaKeyPairGenerator mldsa_gen; + + mldsa_gen = new Org.BouncyCastle.Crypto.Generators.MLDsaKeyPairGenerator(); + mldsa_gen.Init(new Org.BouncyCastle.Crypto.Parameters.MLDsaKeyGenerationParameters(random, Org.BouncyCastle.Crypto.Parameters.MLDsaParameters.ml_dsa_65)); + sig_alg = "2.16.840.1.101.3.4.3.18"; + return mldsa_gen.GenerateKeyPair(); + } + default: + throw new ArgumentException($"unsupported CA algorithm '{ca_algo}'"); + } + } + // Builds a CA with a SEPARATE RA encryption certificate (RSA): the CA/signing cert carries // digitalSignature+keyCertSign (no keyEncipherment), the RA cert carries keyEncipherment. // GetCACert then presents both, and requests must be encrypted to the RA cert. - public static TestCa CreateWithRaEncryption(string enc_algo = "rsa") { - RsaKeyPairGenerator ca_gen; + public static TestCa CreateWithRaEncryption(string enc_algo = "rsa", string ca_algo = "rsa") { AsymmetricCipherKeyPair ca_pair; + string ca_sig_alg; X509Name ca_name; X509V3CertificateGenerator ca_cg; TestCa ca; @@ -76,9 +144,7 @@ public static TestCa CreateWithRaEncryption(string enc_algo = "rsa") { int ra_key_usage; X509V3CertificateGenerator ra_cg; - ca_gen = new RsaKeyPairGenerator(); - ca_gen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); - ca_pair = ca_gen.GenerateKeyPair(); + ca_pair = GenerateCaKeyPair(ca_algo, out ca_sig_alg); ca_name = new X509Name("CN=Test SCEP CA (split)"); ca_cg = new X509V3CertificateGenerator(); ca_cg.SetSerialNumber(BigInteger.One); @@ -88,7 +154,8 @@ public static TestCa CreateWithRaEncryption(string enc_algo = "rsa") { ca_cg.SetNotAfter(DateTime.UtcNow.AddYears(5)); ca_cg.SetPublicKey(ca_pair.Public); ca_cg.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(KeyUsage.DigitalSignature | KeyUsage.KeyCertSign)); - ca = new TestCa(ca_pair, ca_cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", ca_pair.Private))); + ca = new TestCa(ca_pair, ca_cg.Generate(new Asn1SignatureFactory(ca_sig_alg, ca_pair.Private))); + ca._ca_signature_algorithm = ca_sig_alg; ra_pair = GenerateRaKeyPair(enc_algo, out ra_key_usage); ra_cg = new X509V3CertificateGenerator(); @@ -101,7 +168,7 @@ public static TestCa CreateWithRaEncryption(string enc_algo = "rsa") { ra_cg.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(ra_key_usage)); ca._encryption_key = ra_pair; - ca.EncryptionCert = new X509Certificate2(ra_cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", ca_pair.Private)).GetEncoded()); + ca.EncryptionCert = new X509Certificate2(ra_cg.Generate(new Asn1SignatureFactory(ca_sig_alg, ca_pair.Private)).GetEncoded()); return ca; } @@ -194,7 +261,7 @@ public Org.BouncyCastle.X509.X509Certificate Issue(AsymmetricKeyParameter subjec cg.SetPublicKey(subject_public_key); Org.BouncyCastle.X509.X509Certificate issued; - issued = cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", KeyPair.Private)); + issued = cg.Generate(new Asn1SignatureFactory(_ca_signature_algorithm, KeyPair.Private)); _issued_by_serial[issued.SerialNumber.ToString(16).ToUpperInvariant()] = issued; return issued; } @@ -209,7 +276,7 @@ public Org.BouncyCastle.X509.X509Certificate IssueExpired(AsymmetricKeyParameter cg.SetNotBefore(DateTime.UtcNow.AddYears(-2)); cg.SetNotAfter(DateTime.UtcNow.AddYears(-1)); cg.SetPublicKey(subject_public_key); - return cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", KeyPair.Private)); + return cg.Generate(new Asn1SignatureFactory(_ca_signature_algorithm, KeyPair.Private)); } // Builds a SUCCESS CertRep: SignedData (signed by CA) whose content is EnvelopedData (to the recipient cert) @@ -245,7 +312,7 @@ public Org.BouncyCastle.X509.X509Crl GenerateCrl() { crl_gen.SetThisUpdate(DateTime.UtcNow.AddMinutes(-5)); crl_gen.SetNextUpdate(DateTime.UtcNow.AddDays(7)); crl_gen.AddCrlEntry(BigInteger.ValueOf(99), DateTime.UtcNow.AddMinutes(-1), Org.BouncyCastle.Asn1.X509.CrlReason.KeyCompromise); - return crl_gen.Generate(new Asn1SignatureFactory("SHA256WITHRSA", KeyPair.Private)); + return crl_gen.Generate(new Asn1SignatureFactory(_ca_signature_algorithm, KeyPair.Private)); } public byte[] BuildSuccessCrlRep(Org.BouncyCastle.X509.X509Crl crl, X509Certificate2 recipient_cert, string trans_id, byte[] recipient_nonce) { From 1ad1d07cb79dba8fa9cb5d4dfd92a2219002b590 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 16:57:09 -0600 Subject: [PATCH 09/13] Upgrade BouncyCastle 2.5.0 -> 2.6.1 (enables ML-DSA/SLH-DSA CMS signing); fix DerSequence ambiguity + DigestAlgOid deprecation --- src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs | 2 +- .../ScepTestClient.Crypto.BouncyCastle.csproj | 2 +- tests/ScepTestClient.Tests/BcEncodeTests.cs | 2 +- tests/ScepTestClient.Tests/Fakes/TestCa.cs | 2 +- tests/ScepTestClient.Tests/ScepTestClient.Tests.csproj | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs index 7ac67ba..c1670b3 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs @@ -70,7 +70,7 @@ private static X509Extensions BuildExtensions(Pkcs10 csr) { sid_value = new DerOctetString(System.Text.Encoding.ASCII.GetBytes(csr.Sid!)); sid_seq = new DerSequence(new DerObjectIdentifier("1.3.6.1.4.1.311.25.2.1"), new DerTaggedObject(true, 0, sid_value)); - gen.AddExtension(new DerObjectIdentifier(SidExtensionOid), false, new DerSequence(sid_seq)); + gen.AddExtension(new DerObjectIdentifier(SidExtensionOid), false, new DerSequence((Asn1Encodable)sid_seq)); any = true; } diff --git a/src/ScepTestClient.Crypto.BouncyCastle/ScepTestClient.Crypto.BouncyCastle.csproj b/src/ScepTestClient.Crypto.BouncyCastle/ScepTestClient.Crypto.BouncyCastle.csproj index a8e7f9a..31891ca 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/ScepTestClient.Crypto.BouncyCastle.csproj +++ b/src/ScepTestClient.Crypto.BouncyCastle/ScepTestClient.Crypto.BouncyCastle.csproj @@ -8,7 +8,7 @@ - + diff --git a/tests/ScepTestClient.Tests/BcEncodeTests.cs b/tests/ScepTestClient.Tests/BcEncodeTests.cs index 251bf69..ed27990 100644 --- a/tests/ScepTestClient.Tests/BcEncodeTests.cs +++ b/tests/ScepTestClient.Tests/BcEncodeTests.cs @@ -85,6 +85,6 @@ public void Encodes_pkcsreq_honors_digest_algorithm_oid() { signed = new CmsSignedData(der); signer = signed.GetSignerInfos().GetSigners().Cast().First(); - Assert.Equal(expected_oid, signer.DigestAlgOid); + Assert.Equal(expected_oid, signer.DigestAlgorithmID.Algorithm.Id); } } diff --git a/tests/ScepTestClient.Tests/Fakes/TestCa.cs b/tests/ScepTestClient.Tests/Fakes/TestCa.cs index 6f3f07c..a1b5e14 100644 --- a/tests/ScepTestClient.Tests/Fakes/TestCa.cs +++ b/tests/ScepTestClient.Tests/Fakes/TestCa.cs @@ -424,7 +424,7 @@ public byte[] HandlePkiOperation(byte[] pkcs_req_der) { } // 3. Forbidden digest (MD5) -> badAlg ("0"). - if (signer.DigestAlgOid == "1.2.840.113549.2.5") { + if (signer.DigestAlgorithmID.Algorithm.Id == "1.2.840.113549.2.5") { return BuildFailureCertRep(RecipientFrom(signed, signer), TransIdFrom(signer), NonceFrom(signer), "0"); } diff --git a/tests/ScepTestClient.Tests/ScepTestClient.Tests.csproj b/tests/ScepTestClient.Tests/ScepTestClient.Tests.csproj index f37f34d..fc0ed17 100644 --- a/tests/ScepTestClient.Tests/ScepTestClient.Tests.csproj +++ b/tests/ScepTestClient.Tests/ScepTestClient.Tests.csproj @@ -13,7 +13,7 @@ - + From 34bf5b4c4f8a778cffb7cdeb42c2dfe8e77c2587 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 16:59:51 -0600 Subject: [PATCH 10/13] Recipient-aware: ML-DSA CA signing via SignerInfoGeneratorBuilder (BC 2.6.1); mldsa-rsa round-trip + mldsa-only finding profiles --- tests/ScepTestClient.Tests/EndpointMatrixTests.cs | 2 ++ tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs | 2 ++ tests/ScepTestClient.Tests/Fakes/TestCa.cs | 14 ++++++++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/ScepTestClient.Tests/EndpointMatrixTests.cs b/tests/ScepTestClient.Tests/EndpointMatrixTests.cs index 555c810..51a8bd0 100644 --- a/tests/ScepTestClient.Tests/EndpointMatrixTests.cs +++ b/tests/ScepTestClient.Tests/EndpointMatrixTests.cs @@ -19,6 +19,7 @@ public sealed class EndpointMatrixTests { [InlineData("ec-encrypt")] [InlineData("ec-dual")] [InlineData("ecdsa-rsa")] + [InlineData("mldsa-rsa")] public async Task Enroll_succeeds_against_supported_profile(string profile) { FakeScepServer server; ScepResult outcome; @@ -36,6 +37,7 @@ public async Task Enroll_succeeds_against_supported_profile(string profile) { [Theory] [InlineData("mlkem-encrypt", "ML-KEM")] [InlineData("signing-only", "envelop")] + [InlineData("mldsa-only", "envelop")] public async Task Enroll_fails_with_finding_against_unsupported_profile(string profile, string expected_in_error) { FakeScepServer server; ScepResult outcome; diff --git a/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs b/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs index 93ad638..c33c0a1 100644 --- a/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs +++ b/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs @@ -48,6 +48,8 @@ public static async Task StartAsync(TestCa? ca_override) { { "ec-encrypt", TestCa.CreateWithRaEncryption("ec") }, { "ec-dual", TestCa.Create("ec") }, { "ecdsa-rsa", TestCa.CreateWithRaEncryption("rsa", "ec") }, + { "mldsa-rsa", TestCa.CreateWithRaEncryption("rsa", "ml-dsa") }, + { "mldsa-only", TestCa.Create("ml-dsa") }, { "mlkem-encrypt", TestCa.CreateWithRaEncryption("ml-kem") }, { "signing-only", TestCa.CreateSigningOnly() }, }; diff --git a/tests/ScepTestClient.Tests/Fakes/TestCa.cs b/tests/ScepTestClient.Tests/Fakes/TestCa.cs index a1b5e14..d764aaf 100644 --- a/tests/ScepTestClient.Tests/Fakes/TestCa.cs +++ b/tests/ScepTestClient.Tests/Fakes/TestCa.cs @@ -249,6 +249,16 @@ public byte[] GetCaCertBundleDer() { return gen.Generate(new CmsProcessableByteArray(System.Array.Empty()), false).GetEncoded(); } + // Builds a CMS SignerInfoGenerator for the CA key via the SignerInfoGeneratorBuilder path, which + // in BC 2.6.1 supports RSA, ECDSA, and ML-DSA/SLH-DSA signing keys uniformly (the legacy AddSigner + // overloads cannot sign with PQ keys). The base table carries the SCEP signed attributes; + // contentType + messageDigest are added automatically. + private SignerInfoGenerator BuildCaSigner(Org.BouncyCastle.Asn1.Cms.AttributeTable signed_attrs) { + return new SignerInfoGeneratorBuilder() + .WithSignedAttributeGenerator(new DefaultSignedAttributeTableGenerator(signed_attrs)) + .Build(new Asn1SignatureFactory(_ca_signature_algorithm, KeyPair.Private), Certificate); + } + public Org.BouncyCastle.X509.X509Certificate Issue(AsymmetricKeyParameter subject_public_key, string subject_dn) { X509V3CertificateGenerator cg; @@ -351,7 +361,7 @@ public byte[] BuildFailureCertRep(X509Certificate2 recipient_cert, string trans_ ca_cert_store = CollectionUtilities.CreateStore(new[] { Certificate }); signed_gen = new CmsSignedDataGenerator(new SecureRandom()); - signed_gen.AddSigner(KeyPair.Private, Certificate, CmsSignedGenerator.DigestSha256, new Org.BouncyCastle.Asn1.Cms.AttributeTable(attrs), null); + signed_gen.AddSignerInfoGenerator(BuildCaSigner(new Org.BouncyCastle.Asn1.Cms.AttributeTable(attrs))); signed_gen.AddCertificates(ca_cert_store); signed_data = signed_gen.Generate(new CmsProcessableByteArray(degenerate_bytes), true); return signed_data.GetEncoded(); @@ -389,7 +399,7 @@ private byte[] EnvelopeAndSign(byte[] degenerate_bytes, X509Certificate2 recipie ca_cert_store = CollectionUtilities.CreateStore(new[] { Certificate }); signed_gen = new CmsSignedDataGenerator(new SecureRandom()); - signed_gen.AddSigner(KeyPair.Private, Certificate, CmsSignedGenerator.DigestSha256, signed_attr_table, null); + signed_gen.AddSignerInfoGenerator(BuildCaSigner(signed_attr_table)); signed_gen.AddCertificates(ca_cert_store); enveloped_content = new CmsProcessableByteArray(enveloped_bytes); From 40530500c81f2b1d888eeda0ae8280ad1641f894 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 17:27:10 -0600 Subject: [PATCH 11/13] Recipient-aware: ML-KEM KEMRecipientInfo enveloping (RFC 9629, hand-rolled CBC+GCM) + SLH-DSA CA; full PQ profile matrix round-trips --- .../BcEnvelope.cs | 13 +- .../BcKemEnvelope.cs | 219 ++++++++++++++++++ .../BcKemRecipientInfo.cs | 177 ++++++++++++++ .../EndpointMatrixTests.cs | 4 +- .../Fakes/FakeScepServer.cs | 2 + tests/ScepTestClient.Tests/Fakes/TestCa.cs | 25 +- .../ScepTestClient.Tests/KemEnvelopeTests.cs | 45 ++++ .../RecipientEnvelopeTests.cs | 13 +- 8 files changed, 480 insertions(+), 18 deletions(-) create mode 100644 src/ScepTestClient.Crypto.BouncyCastle/BcKemEnvelope.cs create mode 100644 src/ScepTestClient.Crypto.BouncyCastle/BcKemRecipientInfo.cs create mode 100644 tests/ScepTestClient.Tests/KemEnvelopeTests.cs diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs index 30f3b3d..48eda2e 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcEnvelope.cs @@ -45,9 +45,16 @@ public static byte[] Build(X509Certificate2 recipient_cert, byte[] content_der, bc_cert, CmsEnvelopedGenerator.Aes128Wrap); } else if (algorithm_oid.StartsWith(OidMlKemArc, StringComparison.Ordinal)) { - // ML-KEM (RFC 9629 KEMRecipientInfo) — BouncyCastle 2.5.0 has no CMS KEM recipient generator; - // would be a hand-rolled drop-in here, or supplied by an external provider. - throw new NotSupportedException("ML-KEM (KEMRecipientInfo) recipients are not implemented by this provider"); + Org.BouncyCastle.Crypto.Parameters.MLKemPublicKeyParameters mlkem_public; + byte[] key_id; + + // ML-KEM (RFC 9629 KEMRecipientInfo): hand-rolled (BC has no CMS KEM recipient generator). + // The SCEP content stays AES-CBC; only the CEK is KEM-wrapped. Returns the full ContentInfo. + bc_cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(recipient_cert.RawData); + mlkem_public = (Org.BouncyCastle.Crypto.Parameters.MLKemPublicKeyParameters)bc_cert.GetPublicKey(); + key_id = Org.BouncyCastle.Security.DigestUtilities.CalculateDigest("SHA-1", + bc_cert.CertificateStructure.SubjectPublicKeyInfo.PublicKey.GetBytes()); + return BcKemEnvelope.EncryptCbc(content_der, mlkem_public, key_id, content_encryption_oid); } else { throw new NotSupportedException($"recipient key algorithm '{algorithm_oid}' cannot be used to encrypt a SCEP request"); } diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcKemEnvelope.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcKemEnvelope.cs new file mode 100644 index 0000000..21bca04 --- /dev/null +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcKemEnvelope.cs @@ -0,0 +1,219 @@ +using System; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Cms; +using Org.BouncyCastle.Asn1.Nist; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; + +namespace ScepTestClient.Crypto.BouncyCastle; + +// CMS EnvelopedData (AES-CBC) / AuthEnvelopedData (AES-GCM) carrying an RFC 9629 ML-KEM +// KEMRecipientInfo. BouncyCastle 2.6.x has no managed builder for KEM recipients, so the CMS is +// assembled by hand. SCEP uses the CBC path (EnvelopedData); GCM is ported for completeness. +internal static class BcKemEnvelope { + private static readonly DerObjectIdentifier IdOriKem = new DerObjectIdentifier("1.2.840.113549.1.9.16.13.3"); + private static readonly DerObjectIdentifier IdAes256Gcm = NistObjectIdentifiers.IdAes256Gcm; + // id-ct-authEnvelopedData { 1.2.840.113549.1.9.16.1.23 } + private static readonly DerObjectIdentifier IdCtAuthEnvelopedData = new DerObjectIdentifier("1.2.840.113549.1.9.16.1.23"); + private const string OidAes128Cbc = "2.16.840.1.101.3.4.1.2"; + private const string OidAes256Cbc = "2.16.840.1.101.3.4.1.42"; + private const int GcmTagLenBytes = 16; + + // ------------------------------------------------------------------------- + // CBC — EnvelopedData (RFC 5652). content_cipher_oid is honored (AES-128/256-CBC). + // ------------------------------------------------------------------------- + + public static byte[] EncryptCbc(byte[] plaintext, MLKemPublicKeyParameters recipient_public, byte[] recipient_key_id, string content_cipher_oid) { + SecureRandom random; + byte[] cek; + byte[] iv; + byte[] encrypted_content; + AlgorithmIdentifier content_enc_alg; + EncryptedContentInfo eci; + RecipientInfo recipient_info; + EnvelopedData enveloped_data; + + random = new SecureRandom(); + cek = new byte[CekLength(content_cipher_oid)]; + random.NextBytes(cek); + iv = new byte[16]; + random.NextBytes(iv); + + encrypted_content = AesCbc(true, cek, iv, plaintext); + content_enc_alg = new AlgorithmIdentifier(new DerObjectIdentifier(content_cipher_oid), new DerOctetString(iv)); + eci = new EncryptedContentInfo(CmsObjectIdentifiers.Data, content_enc_alg, new DerOctetString(encrypted_content)); + + recipient_info = BcKemRecipientInfo.CreateRecipientInfo(cek, recipient_public, recipient_key_id, null); + Array.Clear(cek, 0, cek.Length); + + enveloped_data = new EnvelopedData(null, new DerSet(recipient_info), eci, (Asn1Set)null!); + return new ContentInfo(CmsObjectIdentifiers.EnvelopedData, enveloped_data).GetDerEncoded(); + } + + // Decrypt either EnvelopedData (CBC) or AuthEnvelopedData (GCM), auto-detected by content type. + public static byte[] Decrypt(byte[] der, MLKemPrivateKeyParameters recipient_private) { + ContentInfo content_info; + DerObjectIdentifier content_type; + + content_info = ContentInfo.GetInstance(Asn1Object.FromByteArray(der)); + content_type = content_info.ContentType; + + if (CmsObjectIdentifiers.EnvelopedData.Equals(content_type)) { return DecryptCbc(content_info, recipient_private); } + if (IdCtAuthEnvelopedData.Equals(content_type)) { return DecryptGcm(content_info, recipient_private); } + throw new InvalidOperationException("Unsupported CMS content type: " + content_type); + } + + private static byte[] DecryptCbc(ContentInfo content_info, MLKemPrivateKeyParameters recipient_private) { + EnvelopedData enveloped_data; + Asn1Sequence kem_ri; + byte[] cek; + EncryptedContentInfo eci; + byte[] iv; + byte[] encrypted_content; + byte[] plaintext; + + enveloped_data = EnvelopedData.GetInstance(content_info.Content); + kem_ri = FindKemRecipient(enveloped_data.RecipientInfos) ?? throw new InvalidOperationException("No id-ori-kem recipient"); + cek = BcKemRecipientInfo.RecoverCek(kem_ri, recipient_private); + + eci = enveloped_data.EncryptedContentInfo; + iv = Asn1OctetString.GetInstance(eci.ContentEncryptionAlgorithm.Parameters).GetOctets(); + encrypted_content = eci.EncryptedContent.GetOctets(); + plaintext = AesCbc(false, cek, iv, encrypted_content); + Array.Clear(cek, 0, cek.Length); + return plaintext; + } + + // ------------------------------------------------------------------------- + // GCM — AuthEnvelopedData (RFC 5083/5084), AES-256-GCM. Ported for completeness; not used by SCEP. + // ------------------------------------------------------------------------- + + public static byte[] EncryptGcm(byte[] plaintext, MLKemPublicKeyParameters recipient_public, byte[] recipient_key_id) { + SecureRandom random; + byte[] cek; + byte[] nonce; + byte[] cipher_with_tag; + byte[] ciphertext; + byte[] tag; + Asn1EncodableVector gcm_params; + AlgorithmIdentifier content_enc_alg; + EncryptedContentInfo eci; + RecipientInfo recipient_info; + Asn1EncodableVector v; + + random = new SecureRandom(); + cek = new byte[32]; + random.NextBytes(cek); + nonce = new byte[12]; + random.NextBytes(nonce); + + cipher_with_tag = AesGcm(true, cek, nonce, plaintext, GcmTagLenBytes); + ciphertext = new byte[cipher_with_tag.Length - GcmTagLenBytes]; + tag = new byte[GcmTagLenBytes]; + Array.Copy(cipher_with_tag, 0, ciphertext, 0, ciphertext.Length); + Array.Copy(cipher_with_tag, ciphertext.Length, tag, 0, GcmTagLenBytes); + + gcm_params = new Asn1EncodableVector(); + gcm_params.Add(new DerOctetString(nonce)); + gcm_params.Add(new DerInteger(GcmTagLenBytes)); + content_enc_alg = new AlgorithmIdentifier(IdAes256Gcm, new DerSequence(gcm_params)); + eci = new EncryptedContentInfo(CmsObjectIdentifiers.Data, content_enc_alg, new DerOctetString(ciphertext)); + + recipient_info = BcKemRecipientInfo.CreateRecipientInfo(cek, recipient_public, recipient_key_id, null); + Array.Clear(cek, 0, cek.Length); + + v = new Asn1EncodableVector(); + v.Add(new DerInteger(0)); // version (RFC 5083) + v.Add(new DerSet(recipient_info)); // recipientInfos + v.Add(eci); // authEncryptedContentInfo + v.Add(new DerOctetString(tag)); // mac (GCM tag) + return new ContentInfo(IdCtAuthEnvelopedData, new DerSequence(v)).GetDerEncoded(); + } + + private static byte[] DecryptGcm(ContentInfo content_info, MLKemPrivateKeyParameters recipient_private) { + Asn1Sequence auth_env; + int idx; + Asn1Set recipient_infos; + EncryptedContentInfo eci; + byte[] tag; + Asn1Sequence kem_ri; + byte[] cek; + Asn1Sequence gcm_params; + byte[] nonce; + int icv_len; + byte[] ciphertext; + byte[] cipher_with_tag; + byte[] plaintext; + + auth_env = Asn1Sequence.GetInstance(content_info.Content); + idx = 0; + idx++; // version + if (auth_env[idx] is Asn1TaggedObject t0 && t0.TagNo == 0) { idx++; } // originatorInfo [0] + recipient_infos = Asn1Set.GetInstance(auth_env[idx++]); + eci = EncryptedContentInfo.GetInstance(auth_env[idx++]); + if (auth_env[idx] is Asn1TaggedObject t1 && t1.TagNo == 1) { idx++; } // authAttrs [1] + tag = Asn1OctetString.GetInstance(auth_env[idx++]).GetOctets(); + + kem_ri = FindKemRecipient(recipient_infos) ?? throw new InvalidOperationException("No id-ori-kem recipient"); + cek = BcKemRecipientInfo.RecoverCek(kem_ri, recipient_private); + + gcm_params = Asn1Sequence.GetInstance(eci.ContentEncryptionAlgorithm.Parameters); + nonce = Asn1OctetString.GetInstance(gcm_params[0]).GetOctets(); + icv_len = gcm_params.Count > 1 ? DerInteger.GetInstance(gcm_params[1]).IntValueExact : 12; + ciphertext = eci.EncryptedContent.GetOctets(); + + cipher_with_tag = new byte[ciphertext.Length + tag.Length]; + Array.Copy(ciphertext, 0, cipher_with_tag, 0, ciphertext.Length); + Array.Copy(tag, 0, cipher_with_tag, ciphertext.Length, tag.Length); + plaintext = AesGcm(false, cek, nonce, cipher_with_tag, icv_len); + Array.Clear(cek, 0, cek.Length); + return plaintext; + } + + // ------------------------------------------------------------------------- + // helpers + // ------------------------------------------------------------------------- + + private static Asn1Sequence? FindKemRecipient(Asn1Set recipient_infos) { + foreach (Asn1Encodable element in recipient_infos) { + Asn1Object obj; + + obj = element.ToAsn1Object(); + if (obj is Asn1TaggedObject tagged && tagged.TagNo == 4) { + Asn1Sequence ori; + DerObjectIdentifier ori_type; + + ori = Asn1Sequence.GetInstance(tagged, false); + ori_type = DerObjectIdentifier.GetInstance(ori[0]); + if (IdOriKem.Equals(ori_type)) { return Asn1Sequence.GetInstance(ori[1]); } + } + } + return null; + } + + private static int CekLength(string content_cipher_oid) { + if (content_cipher_oid == OidAes256Cbc) { return 32; } + if (content_cipher_oid == OidAes128Cbc) { return 16; } + return 16; // default to AES-128 + } + + private static byte[] AesCbc(bool encrypt, byte[] key, byte[] iv, byte[] input) { + IBufferedCipher cipher; + + cipher = CipherUtilities.GetCipher("AES/CBC/PKCS7Padding"); + cipher.Init(encrypt, new ParametersWithIV(new KeyParameter(key), iv)); + return cipher.DoFinal(input); + } + + private static byte[] AesGcm(bool encrypt, byte[] key, byte[] nonce, byte[] input, int tag_len_bytes) { + IBufferedCipher cipher; + AeadParameters parameters; + + cipher = CipherUtilities.GetCipher("AES/GCM/NoPadding"); + parameters = new AeadParameters(new KeyParameter(key), tag_len_bytes * 8, nonce); + cipher.Init(encrypt, parameters); + return cipher.DoFinal(input); + } +} diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcKemRecipientInfo.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcKemRecipientInfo.cs new file mode 100644 index 0000000..e815706 --- /dev/null +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcKemRecipientInfo.cs @@ -0,0 +1,177 @@ +using System; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Cms; +using Org.BouncyCastle.Asn1.Nist; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Kems; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; + +namespace ScepTestClient.Crypto.BouncyCastle; + +// RFC 9629 KEMRecipientInfo for ML-KEM, hand-rolled because BouncyCastle 2.6.x does not expose a +// CMS-level KEM recipient generator. Profile: HKDF-SHA256 KDF, AES-256 key wrap. +internal static class BcKemRecipientInfo { + // id-ori-kem { 1.2.840.113549.1.9.16.13.3 } + private static readonly DerObjectIdentifier IdOriKem = new DerObjectIdentifier("1.2.840.113549.1.9.16.13.3"); + // id-alg-hkdf-with-sha256 { 1.2.840.113549.1.9.16.3.28 } (RFC 8619) + private static readonly DerObjectIdentifier IdAlgHkdfWithSha256 = new DerObjectIdentifier("1.2.840.113549.1.9.16.3.28"); + private static readonly DerObjectIdentifier IdAes256Wrap = NistObjectIdentifiers.IdAes256Wrap; + + private const int KekLengthBytes = 32; // AES-256 KEK + + // Encrypt side: wrap the caller's content-encryption key (CEK) for an ML-KEM recipient and return + // a fully-encoded KEMRecipientInfo carried as OtherRecipientInfo ([4]). + public static RecipientInfo CreateRecipientInfo(byte[] cek, MLKemPublicKeyParameters recipient_public_key, byte[] recipient_key_id, byte[]? ukm) { + MLKemEncapsulator encapsulator; + MLKemParameters kem_parameters; + byte[] shared_secret; + byte[] kem_ciphertext; + AlgorithmIdentifier kem_alg_id; + AlgorithmIdentifier kdf_alg_id; + AlgorithmIdentifier wrap_alg_id; + byte[] other_info; + byte[] kek; + byte[] wrapped_cek; + Asn1EncodableVector v; + DerSequence kem_recipient_info; + Asn1EncodableVector ori; + + kem_parameters = recipient_public_key.Parameters; + encapsulator = new MLKemEncapsulator(kem_parameters); + encapsulator.Init(recipient_public_key); + kem_ciphertext = new byte[encapsulator.EncapsulationLength]; + shared_secret = new byte[encapsulator.SecretLength]; + encapsulator.Encapsulate(kem_ciphertext, 0, kem_ciphertext.Length, shared_secret, 0, shared_secret.Length); + + kem_alg_id = new AlgorithmIdentifier(KemOidFor(kem_parameters)); + kdf_alg_id = new AlgorithmIdentifier(IdAlgHkdfWithSha256); // HKDF-SHA256: parameters absent + wrap_alg_id = new AlgorithmIdentifier(IdAes256Wrap); + + other_info = EncodeOtherInfo(wrap_alg_id, KekLengthBytes, ukm); + kek = HkdfDerive(shared_secret, other_info, KekLengthBytes); + wrapped_cek = AesKeyWrap(kek, cek); + + Array.Clear(shared_secret, 0, shared_secret.Length); + Array.Clear(kek, 0, kek.Length); + + v = new Asn1EncodableVector(); + v.Add(new DerInteger(0)); // version + v.Add(new DerTaggedObject(false, 0, new DerOctetString(recipient_key_id)));// rid: subjectKeyIdentifier [0] + v.Add(kem_alg_id); // kem + v.Add(new DerOctetString(kem_ciphertext)); // kemct + v.Add(kdf_alg_id); // kdf + v.Add(new DerInteger(KekLengthBytes)); // kekLength + if (ukm != null) { + v.Add(new DerTaggedObject(true, 0, new DerOctetString(ukm))); // ukm [0] EXPLICIT OPTIONAL + } + v.Add(wrap_alg_id); // wrap + v.Add(new DerOctetString(wrapped_cek)); // encryptedKey + + kem_recipient_info = new DerSequence(v); + + // OtherRecipientInfo ::= SEQUENCE { oriType OID, oriValue ANY } + ori = new Asn1EncodableVector(); + ori.Add(IdOriKem); + ori.Add(kem_recipient_info); + + // RecipientInfo ::= CHOICE { ... ori [4] IMPLICIT OtherRecipientInfo } + return RecipientInfo.GetInstance(new DerTaggedObject(false, 4, new DerSequence(ori))); + } + + // Decrypt side: recover the CEK from a KEMRecipientInfo SEQUENCE (the oriValue) with the recipient + // ML-KEM private key. + public static byte[] RecoverCek(Asn1Sequence kem_recipient_info, MLKemPrivateKeyParameters recipient_private_key) { + int idx; + byte[] kem_ciphertext; + int kek_length; + byte[]? ukm; + AlgorithmIdentifier wrap_alg_id; + byte[] wrapped_cek; + MLKemDecapsulator decapsulator; + byte[] shared_secret; + byte[] other_info; + byte[] kek; + byte[] cek; + + ukm = null; + idx = 0; + idx++; // version + idx++; // rid (recipient already selected by key id) + idx++; // kem AlgorithmIdentifier (param set fixed by the key) + kem_ciphertext = Asn1OctetString.GetInstance(kem_recipient_info[idx++]).GetOctets(); + idx++; // kdf AlgorithmIdentifier (assume HKDF-SHA256 per profile) + kek_length = DerInteger.GetInstance(kem_recipient_info[idx++]).IntValueExact; + + if (kem_recipient_info[idx] is Asn1TaggedObject tagged && tagged.TagNo == 0) { + ukm = Asn1OctetString.GetInstance(tagged.GetExplicitBaseObject()).GetOctets(); + idx++; + } + + wrap_alg_id = AlgorithmIdentifier.GetInstance(kem_recipient_info[idx++]); + wrapped_cek = Asn1OctetString.GetInstance(kem_recipient_info[idx++]).GetOctets(); + + decapsulator = new MLKemDecapsulator(recipient_private_key.Parameters); + decapsulator.Init(recipient_private_key); + shared_secret = new byte[decapsulator.SecretLength]; + decapsulator.Decapsulate(kem_ciphertext, 0, kem_ciphertext.Length, shared_secret, 0, shared_secret.Length); + other_info = EncodeOtherInfo(wrap_alg_id, kek_length, ukm); + kek = HkdfDerive(shared_secret, other_info, kek_length); + cek = AesKeyUnwrap(kek, wrapped_cek); + + Array.Clear(shared_secret, 0, shared_secret.Length); + Array.Clear(kek, 0, kek.Length); + return cek; + } + + // CMSORIforKEMOtherInfo ::= SEQUENCE { wrap KeyEncryptionAlgorithmIdentifier, + // kekLength INTEGER (1..65535), ukm [0] EXPLICIT UserKeyingMaterial OPTIONAL } + private static byte[] EncodeOtherInfo(AlgorithmIdentifier wrap_alg_id, int kek_length, byte[]? ukm) { + Asn1EncodableVector v; + + v = new Asn1EncodableVector(); + v.Add(wrap_alg_id); + v.Add(new DerInteger(kek_length)); + if (ukm != null) { + v.Add(new DerTaggedObject(true, 0, new DerOctetString(ukm))); + } + return new DerSequence(v).GetDerEncoded(); + } + + private static byte[] HkdfDerive(byte[] ikm, byte[] info, int length) { + HkdfBytesGenerator hkdf; + byte[] okm; + + hkdf = new HkdfBytesGenerator(new Sha256Digest()); + hkdf.Init(new HkdfParameters(ikm, new byte[0], info)); // RFC 9629: salt is the empty string + okm = new byte[length]; + hkdf.GenerateBytes(okm, 0, length); + return okm; + } + + private static byte[] AesKeyWrap(byte[] kek, byte[] key_to_wrap) { + IWrapper wrapper; + + wrapper = WrapperUtilities.GetWrapper("AES"); + wrapper.Init(true, new KeyParameter(kek)); + return wrapper.Wrap(key_to_wrap, 0, key_to_wrap.Length); + } + + private static byte[] AesKeyUnwrap(byte[] kek, byte[] wrapped) { + IWrapper wrapper; + + wrapper = WrapperUtilities.GetWrapper("AES"); + wrapper.Init(false, new KeyParameter(kek)); + return wrapper.Unwrap(wrapped, 0, wrapped.Length); + } + + private static DerObjectIdentifier KemOidFor(MLKemParameters p) { + if (p == MLKemParameters.ml_kem_512) { return NistObjectIdentifiers.id_alg_ml_kem_512; } + if (p == MLKemParameters.ml_kem_768) { return NistObjectIdentifiers.id_alg_ml_kem_768; } + if (p == MLKemParameters.ml_kem_1024) { return NistObjectIdentifiers.id_alg_ml_kem_1024; } + throw new ArgumentException("Unsupported ML-KEM parameter set"); + } +} diff --git a/tests/ScepTestClient.Tests/EndpointMatrixTests.cs b/tests/ScepTestClient.Tests/EndpointMatrixTests.cs index 51a8bd0..3c30fdc 100644 --- a/tests/ScepTestClient.Tests/EndpointMatrixTests.cs +++ b/tests/ScepTestClient.Tests/EndpointMatrixTests.cs @@ -20,6 +20,9 @@ public sealed class EndpointMatrixTests { [InlineData("ec-dual")] [InlineData("ecdsa-rsa")] [InlineData("mldsa-rsa")] + [InlineData("slhdsa-rsa")] + [InlineData("mlkem-encrypt")] + [InlineData("mldsa-mlkem")] public async Task Enroll_succeeds_against_supported_profile(string profile) { FakeScepServer server; ScepResult outcome; @@ -35,7 +38,6 @@ public async Task Enroll_succeeds_against_supported_profile(string profile) { } [Theory] - [InlineData("mlkem-encrypt", "ML-KEM")] [InlineData("signing-only", "envelop")] [InlineData("mldsa-only", "envelop")] public async Task Enroll_fails_with_finding_against_unsupported_profile(string profile, string expected_in_error) { diff --git a/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs b/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs index c33c0a1..c8a8c61 100644 --- a/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs +++ b/tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs @@ -50,7 +50,9 @@ public static async Task StartAsync(TestCa? ca_override) { { "ecdsa-rsa", TestCa.CreateWithRaEncryption("rsa", "ec") }, { "mldsa-rsa", TestCa.CreateWithRaEncryption("rsa", "ml-dsa") }, { "mldsa-only", TestCa.Create("ml-dsa") }, + { "slhdsa-rsa", TestCa.CreateWithRaEncryption("rsa", "slh-dsa") }, { "mlkem-encrypt", TestCa.CreateWithRaEncryption("ml-kem") }, + { "mldsa-mlkem", TestCa.CreateWithRaEncryption("ml-kem", "ml-dsa") }, { "signing-only", TestCa.CreateSigningOnly() }, }; diff --git a/tests/ScepTestClient.Tests/Fakes/TestCa.cs b/tests/ScepTestClient.Tests/Fakes/TestCa.cs index d764aaf..6ddf7c4 100644 --- a/tests/ScepTestClient.Tests/Fakes/TestCa.cs +++ b/tests/ScepTestClient.Tests/Fakes/TestCa.cs @@ -126,6 +126,14 @@ private static AsymmetricCipherKeyPair GenerateCaKeyPair(string ca_algo, out str sig_alg = "2.16.840.1.101.3.4.3.18"; return mldsa_gen.GenerateKeyPair(); } + case "slh-dsa": { + Org.BouncyCastle.Crypto.Generators.SlhDsaKeyPairGenerator slhdsa_gen; + + slhdsa_gen = new Org.BouncyCastle.Crypto.Generators.SlhDsaKeyPairGenerator(); + slhdsa_gen.Init(new Org.BouncyCastle.Crypto.Parameters.SlhDsaKeyGenerationParameters(random, Org.BouncyCastle.Crypto.Parameters.SlhDsaParameters.slh_dsa_sha2_128f)); + sig_alg = "2.16.840.1.101.3.4.3.21"; // SLH-DSA-SHA2-128f + return slhdsa_gen.GenerateKeyPair(); + } default: throw new ArgumentException($"unsupported CA algorithm '{ca_algo}'"); } @@ -584,13 +592,20 @@ private static Org.BouncyCastle.X509.X509Certificate FirstCert(CmsSignedData sig private byte[] DecryptInner(byte[] der) { CmsSignedData signed; MemoryStream env_stream; - CmsEnvelopedData env; signed = new CmsSignedData(der); env_stream = new MemoryStream(); signed.SignedContent.Write(env_stream); - env = new CmsEnvelopedData(env_stream.ToArray()); - return env.GetRecipientInfos().GetRecipients().Cast().First().GetContent(RecipientKey); + return DecryptEnvelopedData(env_stream.ToArray()); + } + + // Decrypts the SCEP inner EnvelopedData. ML-KEM recipients use the hand-rolled RFC 9629 path + // (BC has no CMS KEM recipient); RSA/EC use the standard recipient API. + private byte[] DecryptEnvelopedData(byte[] env_der) { + if (_encryption_key?.Private is Org.BouncyCastle.Crypto.Parameters.MLKemPrivateKeyParameters mlkem_priv) { + return ScepTestClient.Crypto.BouncyCastle.BcKemEnvelope.Decrypt(env_der, mlkem_priv); + } + return new CmsEnvelopedData(env_der).GetRecipientInfos().GetRecipients().Cast().First().GetContent(RecipientKey); } public string PeekMessageType(byte[] der) { @@ -640,7 +655,6 @@ private void DecodeRequest(byte[] der, out X509Certificate2 requester_cert, out SignerInformation signer; Org.BouncyCastle.X509.X509Certificate signer_bc_cert; MemoryStream env_stream; - CmsEnvelopedData env; Org.BouncyCastle.Asn1.Cms.AttributeTable attrs; signed = new CmsSignedData(der); @@ -650,8 +664,7 @@ private void DecodeRequest(byte[] der, out X509Certificate2 requester_cert, out env_stream = new MemoryStream(); signed.SignedContent.Write(env_stream); - env = new CmsEnvelopedData(env_stream.ToArray()); - inner_payload = env.GetRecipientInfos().GetRecipients().Cast().First().GetContent(RecipientKey); + inner_payload = DecryptEnvelopedData(env_stream.ToArray()); trans_id = "tx"; sender_nonce = new byte[16]; diff --git a/tests/ScepTestClient.Tests/KemEnvelopeTests.cs b/tests/ScepTestClient.Tests/KemEnvelopeTests.cs new file mode 100644 index 0000000..ab4f484 --- /dev/null +++ b/tests/ScepTestClient.Tests/KemEnvelopeTests.cs @@ -0,0 +1,45 @@ +using System.Text; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; +using ScepTestClient.Crypto.BouncyCastle; +using Xunit; + +namespace ScepTestClient.Tests; + +// Direct round-trip of the hand-rolled RFC 9629 ML-KEM CMS envelope, for both the SCEP-used CBC +// (EnvelopedData) path and the ported GCM (AuthEnvelopedData) path. +public sealed class KemEnvelopeTests { + private const string Aes128CbcOid = "2.16.840.1.101.3.4.1.2"; + + [Theory] + [InlineData(false)] // AES-128-CBC EnvelopedData (SCEP) + [InlineData(true)] // AES-256-GCM AuthEnvelopedData (ported, not used by SCEP) + public void Kem_envelope_roundtrips(bool gcm) { + MLKemKeyPairGenerator generator; + AsymmetricCipherKeyPair pair; + MLKemPublicKeyParameters pub; + MLKemPrivateKeyParameters priv; + byte[] plaintext; + byte[] key_id; + byte[] der; + byte[] recovered; + + generator = new MLKemKeyPairGenerator(); + generator.Init(new MLKemKeyGenerationParameters(new SecureRandom(), MLKemParameters.ml_kem_768)); + pair = generator.GenerateKeyPair(); + pub = (MLKemPublicKeyParameters)pair.Public; + priv = (MLKemPrivateKeyParameters)pair.Private; + + plaintext = Encoding.UTF8.GetBytes("the quick brown fox jumps over the lazy SCEP request"); + key_id = new byte[20]; + + der = gcm + ? BcKemEnvelope.EncryptGcm(plaintext, pub, key_id) + : BcKemEnvelope.EncryptCbc(plaintext, pub, key_id, Aes128CbcOid); + + recovered = BcKemEnvelope.Decrypt(der, priv); + Assert.Equal(plaintext, recovered); + } +} diff --git a/tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs b/tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs index 22645da..c4c7244 100644 --- a/tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs +++ b/tests/ScepTestClient.Tests/RecipientEnvelopeTests.cs @@ -12,10 +12,10 @@ namespace ScepTestClient.Tests; // RSA key-transport is exercised end-to-end elsewhere; here we assert the unsupported recipient kinds // fail cleanly (no throw) with a recognizable message that Core can turn into a finding. public sealed class RecipientEnvelopeTests { - // ML-KEM has no CMS recipient generator in BouncyCastle 2.5.0, so it must fail cleanly (no throw) - // with a recognizable message. (EC and RSA recipients ARE supported — see the round-trip tests.) + // ML-KEM recipients now envelope via the hand-rolled RFC 9629 KEMRecipientInfo (BC 2.6.1 has the + // KEM primitives but no CMS recipient generator). The request encodes to a non-empty CMS. [Fact] - public void Mlkem_recipient_fails_cleanly() { + public void Mlkem_recipient_envelopes() { BouncyCastleScepCrypto crypto; X509Certificate2 recipient; ScepRequestBuilder builder; @@ -23,7 +23,6 @@ public void Mlkem_recipient_fails_cleanly() { IScepKey subject_key; string error; byte[] der; - bool ok; crypto = new BouncyCastleScepCrypto(); recipient = TestCertFactory.Make("ml-kem", KeyUsage.KeyEncipherment); @@ -34,10 +33,8 @@ public void Mlkem_recipient_fails_cleanly() { .KeySpec("rsa:2048"); Assert.True(builder.Build(out message, out subject_key, out error), error); - ok = message.Encode(crypto, out der, out error); - - Assert.False(ok); - Assert.Contains("ML-KEM", error); + Assert.True(message.Encode(crypto, out der, out error), error); + Assert.True(der.Length > 0); } [Fact] From feffa5d7e624514db9c148b17e02f554138370b2 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 17:35:44 -0600 Subject: [PATCH 12/13] Recipient-aware: fix KEM key-wrap to RFC 3394 AES-KeyWrap (was ECB), null-guard EncryptedContent, assert EnvelopedData version 3; verified by OpenSSL 3.6.2 interop decrypt --- .../BcKemEnvelope.cs | 6 +++++ .../BcKemRecipientInfo.cs | 7 +++--- .../ScepTestClient.Tests/KemEnvelopeTests.cs | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcKemEnvelope.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcKemEnvelope.cs index 21bca04..a056274 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcKemEnvelope.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcKemEnvelope.cs @@ -80,6 +80,9 @@ private static byte[] DecryptCbc(ContentInfo content_info, MLKemPrivateKeyParame eci = enveloped_data.EncryptedContentInfo; iv = Asn1OctetString.GetInstance(eci.ContentEncryptionAlgorithm.Parameters).GetOctets(); + if (eci.EncryptedContent is null) { + throw new InvalidOperationException("EnvelopedData has no EncryptedContent ([0] OPTIONAL; detached content not supported)"); + } encrypted_content = eci.EncryptedContent.GetOctets(); plaintext = AesCbc(false, cek, iv, encrypted_content); Array.Clear(cek, 0, cek.Length); @@ -162,6 +165,9 @@ private static byte[] DecryptGcm(ContentInfo content_info, MLKemPrivateKeyParame gcm_params = Asn1Sequence.GetInstance(eci.ContentEncryptionAlgorithm.Parameters); nonce = Asn1OctetString.GetInstance(gcm_params[0]).GetOctets(); icv_len = gcm_params.Count > 1 ? DerInteger.GetInstance(gcm_params[1]).IntValueExact : 12; + if (eci.EncryptedContent is null) { + throw new InvalidOperationException("AuthEnvelopedData has no EncryptedContent ([0] OPTIONAL; detached content not supported)"); + } ciphertext = eci.EncryptedContent.GetOctets(); cipher_with_tag = new byte[ciphertext.Length + tag.Length]; diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcKemRecipientInfo.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcKemRecipientInfo.cs index e815706..7a604f5 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcKemRecipientInfo.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcKemRecipientInfo.cs @@ -8,7 +8,6 @@ using Org.BouncyCastle.Crypto.Generators; using Org.BouncyCastle.Crypto.Kems; using Org.BouncyCastle.Crypto.Parameters; -using Org.BouncyCastle.Security; namespace ScepTestClient.Crypto.BouncyCastle; @@ -152,10 +151,12 @@ private static byte[] HkdfDerive(byte[] ikm, byte[] info, int length) { return okm; } + // RFC 3394 AES Key Wrap (what id-aesNNN-wrap denotes). "AES" via WrapperUtilities resolves to an + // ECB-padding wrapper, which is NOT key-wrap and is non-interoperable — use AesWrapEngine directly. private static byte[] AesKeyWrap(byte[] kek, byte[] key_to_wrap) { IWrapper wrapper; - wrapper = WrapperUtilities.GetWrapper("AES"); + wrapper = new Org.BouncyCastle.Crypto.Engines.AesWrapEngine(); wrapper.Init(true, new KeyParameter(kek)); return wrapper.Wrap(key_to_wrap, 0, key_to_wrap.Length); } @@ -163,7 +164,7 @@ private static byte[] AesKeyWrap(byte[] kek, byte[] key_to_wrap) { private static byte[] AesKeyUnwrap(byte[] kek, byte[] wrapped) { IWrapper wrapper; - wrapper = WrapperUtilities.GetWrapper("AES"); + wrapper = new Org.BouncyCastle.Crypto.Engines.AesWrapEngine(); wrapper.Init(false, new KeyParameter(kek)); return wrapper.Unwrap(wrapped, 0, wrapped.Length); } diff --git a/tests/ScepTestClient.Tests/KemEnvelopeTests.cs b/tests/ScepTestClient.Tests/KemEnvelopeTests.cs index ab4f484..81cf331 100644 --- a/tests/ScepTestClient.Tests/KemEnvelopeTests.cs +++ b/tests/ScepTestClient.Tests/KemEnvelopeTests.cs @@ -1,4 +1,6 @@ using System.Text; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Cms; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Generators; using Org.BouncyCastle.Crypto.Parameters; @@ -42,4 +44,25 @@ public void Kem_envelope_roundtrips(bool gcm) { recovered = BcKemEnvelope.Decrypt(der, priv); Assert.Equal(plaintext, recovered); } + + // EnvelopedData with an ori (OtherRecipientInfo) recipient MUST be version 3 (RFC 5652 §6.1). + [Fact] + public void Cbc_envelope_is_version_3() { + MLKemKeyPairGenerator generator; + AsymmetricCipherKeyPair pair; + MLKemPublicKeyParameters pub; + byte[] der; + ContentInfo ci; + EnvelopedData ed; + + generator = new MLKemKeyPairGenerator(); + generator.Init(new MLKemKeyGenerationParameters(new SecureRandom(), MLKemParameters.ml_kem_768)); + pair = generator.GenerateKeyPair(); + pub = (MLKemPublicKeyParameters)pair.Public; + + der = BcKemEnvelope.EncryptCbc(Encoding.UTF8.GetBytes("v3"), pub, new byte[20], Aes128CbcOid); + ci = ContentInfo.GetInstance(Asn1Object.FromByteArray(der)); + ed = EnvelopedData.GetInstance(ci.Content); + Assert.Equal(3, ed.Version.IntValueExact); + } } From 519777b96b717934eed3d1edc6c1b04ebd024918 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 17:53:23 -0600 Subject: [PATCH 13/13] PQ enrollment: enroll PQ subject keys over transient RSA transport (issues PQ certs); PQ outer signing (BcSelfSigned/BcPkiMessage builder path) + pure-PQ conformance probe --- src/ScepTestClient.Core/ScepClient.cs | 25 ++++- .../BcPkiMessage.cs | 14 ++- .../BcSelfSigned.cs | 4 +- .../PqSubjectEnrollTests.cs | 91 +++++++++++++++++++ 4 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 tests/ScepTestClient.Tests/PqSubjectEnrollTests.cs diff --git a/src/ScepTestClient.Core/ScepClient.cs b/src/ScepTestClient.Core/ScepClient.cs index caf62c1..6438543 100644 --- a/src/ScepTestClient.Core/ScepClient.cs +++ b/src/ScepTestClient.Core/ScepClient.cs @@ -697,6 +697,7 @@ private bool SelectRecipient(IReadOnlyList certs, out X509Cert private ScepResult BuildPkiMessage(EnrollRequest request, out PkiMessage pki_message, out string error) { Pkcs10 csr; + IScepKey signer_key; pki_message = null!; error = string.Empty; @@ -725,10 +726,32 @@ private ScepResult BuildPkiMessage(EnrollRequest request, out Pki csr.Ekus.Add(eku); } + signer_key = request.Key; + if (Algorithms.KindOf(request.Key.AlgorithmOid) == AlgorithmKind.Signature) { + KeySpec rsa_spec; + string spec_error; + IScepKey transient_signer; + string gen_error; + + // A PQ signature subject key cannot decrypt the SCEP response (RFC 8894 encrypts the + // CertRep to the requester's signing key). Use a transient RSA transport key; the issued + // certificate still carries the PQ subject key from the CSR. + if (!KeySpec.Parse("rsa:2048", out rsa_spec, out spec_error)) { + error = spec_error; + return ScepResult.Fail(ScepClientResult.InvalidArgument, error); + } + if (!Crypto.GenerateKey(rsa_spec, out transient_signer, out gen_error)) { + error = gen_error; + return ScepResult.Fail(ScepClientResult.ProviderError, gen_error); + } + signer_key = transient_signer; + Emit(TraceLevel.Opinion, "Enroll", "subject key is a PQ signature key; using a transient RSA transport key for the SCEP envelope (RFC 8894 requires the requester key to decrypt the CertRep)"); + } + pki_message = new PkiMessage { MessageType = MessageType.PkcsReq, InnerCsr = csr, - SignerKey = request.Key, + SignerKey = signer_key, RecipientCaCert = request.CaCertificate, DigestAlgorithmOid = request.DigestOid, ContentEncryptionAlgorithmOid = request.ContentEncryptionOid, diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs index 4e65eb0..3c640d7 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs @@ -80,7 +80,19 @@ public static byte[] EncodePkiOperation(PkiMessage message, byte[] inner_payload cert_store = CollectionUtilities.CreateStore(new[] { signer_cert }); signed_gen = new CmsSignedDataGenerator(Random); - signed_gen.AddSigner(signing_private_key, signer_cert, message.DigestAlgorithmOid, signed_attr_table, null); + if (signing_private_key is Org.BouncyCastle.Crypto.Parameters.MLDsaPrivateKeyParameters + || signing_private_key is Org.BouncyCastle.Crypto.Parameters.SlhDsaPrivateKeyParameters) { + // PQ outer signature (BC 2.6.1): the legacy AddSigner overloads can't sign with ML-DSA/SLH-DSA; + // use the SignerInfoGeneratorBuilder path. Digest is the draft baseline (SHA-512), picked by BC. + Org.BouncyCastle.Cms.SignerInfoGenerator pq_signer; + + pq_signer = new Org.BouncyCastle.Cms.SignerInfoGeneratorBuilder() + .WithSignedAttributeGenerator(new Org.BouncyCastle.Cms.DefaultSignedAttributeTableGenerator(signed_attr_table)) + .Build(new Org.BouncyCastle.Crypto.Operators.Asn1SignatureFactory(signer_key.AlgorithmOid, signing_private_key, Random), signer_cert); + signed_gen.AddSignerInfoGenerator(pq_signer); + } else { + signed_gen.AddSigner(signing_private_key, signer_cert, message.DigestAlgorithmOid, signed_attr_table, null); + } signed_gen.AddCertificates(cert_store); enveloped_content = new CmsProcessableByteArray(enveloped_bytes); diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcSelfSigned.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcSelfSigned.cs index 5cf158a..b700c9d 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcSelfSigned.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcSelfSigned.cs @@ -19,6 +19,8 @@ public static X509Certificate ForKey(BcKey key, string subject_dn) { cg.SetNotBefore(DateTime.UtcNow.AddMinutes(-5)); cg.SetNotAfter(DateTime.UtcNow.AddDays(1)); cg.SetPublicKey(key.KeyPair.Public); - return cg.Generate(new Asn1SignatureFactory("SHA256WITHRSA", key.KeyPair.Private)); + // Sign with the key's own algorithm: RSA classically, ML-DSA/SLH-DSA by OID (BC 2.6.1). + return cg.Generate(new Asn1SignatureFactory( + BcPqKeys.IsPq(key) ? key.AlgorithmOid : "SHA256WITHRSA", key.KeyPair.Private)); } } diff --git a/tests/ScepTestClient.Tests/PqSubjectEnrollTests.cs b/tests/ScepTestClient.Tests/PqSubjectEnrollTests.cs new file mode 100644 index 0000000..d4a3715 --- /dev/null +++ b/tests/ScepTestClient.Tests/PqSubjectEnrollTests.cs @@ -0,0 +1,91 @@ +using System.IO; +using System.Threading.Tasks; +using ScepTestClient.Core; +using ScepTestClient.Core.Storage; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using ScepTestClient.Tests.Fakes; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqSubjectEnrollTests { + private const string MlDsa65Oid = "2.16.840.1.101.3.4.3.18"; + + // (b) The realistic PQ enrollment: the certified key is ML-DSA, carried over an RSA-signed/decrypted + // SCEP transport (a transient RSA key is generated for the envelope because a PQ signature key + // cannot decrypt the CertRep). The issued certificate carries the ML-DSA subject key. + [Fact] + public async Task Enroll_pq_subject_key_issues_pq_certificate() { + FakeScepServer server; + BouncyCastleScepCrypto crypto; + ScepClient client; + string root; + KeySpec spec; + IScepKey key; + string error; + EnrollRequest request; + ScepResult outcome; + + server = await FakeScepServer.StartAsync(); + try { + crypto = new BouncyCastleScepCrypto(); + ScepClient.Create(new ServerConfig { Id = "pq", Url = server.ProfileUrl("rsa"), PreferPost = true }, crypto, handler: null, out client, out _); + root = Directory.CreateTempSubdirectory().FullName; + + Assert.True(KeySpec.Parse("ml-dsa:65", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + + request = new EnrollRequest { Subject = "CN=pq-subject", Key = key }; + outcome = client.GetNewCertificate(request, new CertStore(root), new UseRecordLog(root)); + + Assert.True(outcome.IsOk, $"{outcome.Status} {outcome.Error}"); + Assert.NotNull(outcome.Value.Certificate); + Assert.Equal(MlDsa65Oid, outcome.Value.Certificate!.GetKeyAlgorithm()); + } finally { + await server.DisposeAsync(); + } + } + + // (a) Conformance probe: the client CAN emit a fully PQ-signed request (ML-DSA outer signature), + // but a server cannot return the CertRep (it can't envelope the response back to an ML-DSA signing + // key) — so the exchange fails, which is the correct outcome to observe. + [Fact] + public async Task Pure_pq_signed_request_is_emitted_but_exchange_cannot_complete() { + FakeScepServer server; + BouncyCastleScepCrypto crypto; + ScepClient client; + ScepResult> ca; + ScepRequestBuilder builder; + PkiMessage message; + IScepKey subject_key; + string error; + byte[] der; + ScepResult result; + + server = await FakeScepServer.StartAsync(); + try { + crypto = new BouncyCastleScepCrypto(); + ScepClient.Create(new ServerConfig { Id = "pqprobe", Url = server.ProfileUrl("rsa"), PreferPost = true }, crypto, handler: null, out client, out _); + ca = client.GetCaCert(); + Assert.True(ca.IsOk, ca.Error); + + builder = ScepRequestBuilder.For(crypto) + .CaCertificate(ca.Value[0]) + .MessageType(ScepTestClient.CryptoApi.MessageType.PkcsReq) + .Subject("CN=pure-pq") + .KeySpec("ml-dsa:65"); // subject == signer -> PQ outer signature + Assert.True(builder.Build(out message, out subject_key, out error), error); + + // The PQ-signed request encodes (the client can emit it). + Assert.True(message.Encode(crypto, out der, out error), error); + Assert.True(der.Length > 0); + + // But the server cannot complete the exchange (cannot envelope the response to an ML-DSA key). + result = client.SubmitPkiOperation(message, subject_key, null); + Assert.False(result.IsOk); + } finally { + await server.DisposeAsync(); + } + } +}