Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
176 changes: 176 additions & 0 deletions src/ScepTestClient.Core/Recipients/RecipientSelector.cs
Original file line number Diff line number Diff line change
@@ -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<RecipientFinding> Findings { get; init; } = Array.Empty<RecipientFinding>();

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<X509Certificate2> 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<X509Certificate2> certs) {
X509Certificate2? sign_cert;
X509Certificate2? enc_cert;
RecipientKind enc_kind;
bool enc_capable_by_algorithm;
bool enc_rejected_for_keyusage;
List<RecipientFinding> findings;

sign_cert = null;
enc_cert = null;
enc_kind = RecipientKind.Unknown;
enc_capable_by_algorithm = false;
enc_rejected_for_keyusage = false;
findings = new List<RecipientFinding>();

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<X509Certificate2> certs) {
X509Certificate2 sign_cert;
X509Certificate2 enc_cert;
RecipientKind enc_kind;
List<RecipientFinding> findings;

findings = new List<RecipientFinding>();
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<X509KeyUsageExtension>().FirstOrDefault();
return ext?.KeyUsages;
}
}
61 changes: 58 additions & 3 deletions src/ScepTestClient.Core/ScepClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -598,7 +599,10 @@ public async Task<ScepResult<EnrollOutcome>> GetNewCertificateAsync(EnrollReques
if (!ca_result.IsOk) {
return ScepResult<EnrollOutcome>.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<EnrollOutcome>.Fail(ScepClientResult.ProtocolError, select_error);
}
request.CaCertificate = recipient;
}

enroll_result = await EnrollAsync(request).ConfigureAwait(false);
Expand Down Expand Up @@ -635,7 +639,10 @@ public ScepResult<EnrollOutcome> GetNewCertificate(EnrollRequest request, Storag
if (!ca_result.IsOk) {
return ScepResult<EnrollOutcome>.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<EnrollOutcome>.Fail(ScepClientResult.ProtocolError, select_error);
}
request.CaCertificate = recipient;
}

enroll_result = Enroll(request);
Expand Down Expand Up @@ -663,8 +670,34 @@ public ScepResult<EnrollOutcome> 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<X509Certificate2> 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<EnrollOutcome> BuildPkiMessage(EnrollRequest request, out PkiMessage pki_message, out string error) {
Pkcs10 csr;
IScepKey signer_key;

pki_message = null!;
error = string.Empty;
Expand Down Expand Up @@ -693,10 +726,32 @@ private ScepResult<EnrollOutcome> 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<EnrollOutcome>.Fail(ScepClientResult.InvalidArgument, error);
}
if (!Crypto.GenerateKey(rsa_spec, out transient_signer, out gen_error)) {
error = gen_error;
return ScepResult<EnrollOutcome>.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,
Expand Down
2 changes: 1 addition & 1 deletion src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading