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
11 changes: 8 additions & 3 deletions src/ScepWright.Core/ScepClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ namespace ScepWright.Core;
public sealed class ScepClient {
private readonly ScepHttpTransport _transport;
private X509Certificate2? _recipient_cert_cache;
// The most recent GetCACert bundle, used as extra candidates when verifying a CertRep signature whose
// signer cert the server did not embed in the response.
private IReadOnlyList<X509Certificate2>? _ca_bundle_cache;

/// <summary>Gets the crypto provider backing this client.</summary>
public IScepCrypto Crypto { get; }
Expand Down Expand Up @@ -124,6 +127,7 @@ public ScepResult<IReadOnlyList<X509Certificate2>> GetCaCert() {
return ScepResult<IReadOnlyList<X509Certificate2>>.Fail(ScepClientResult.CryptoError, error);
}

_ca_bundle_cache = certs;
return ScepResult<IReadOnlyList<X509Certificate2>>.Ok(certs);
}

Expand All @@ -144,6 +148,7 @@ public async Task<ScepResult<IReadOnlyList<X509Certificate2>>> GetCaCertAsync()
return ScepResult<IReadOnlyList<X509Certificate2>>.Fail(ScepClientResult.CryptoError, error);
}

_ca_bundle_cache = certs;
return ScepResult<IReadOnlyList<X509Certificate2>>.Ok(certs);
}

Expand Down Expand Up @@ -612,7 +617,7 @@ private ScepResult<PkiMessage> SendDecodedSync(PkiMessage message) {
if (!raw.IsOk) {
return ScepResult<PkiMessage>.Fail(raw.Status, raw.Error);
}
if (!PkiMessage.Decode(Crypto, raw.Value, message.SignerKey!, CodecOptions.LenientParsing, out decoded, out decode_error)) {
if (!PkiMessage.Decode(Crypto, raw.Value, message.SignerKey!, CodecOptions.LenientParsing, out decoded, out decode_error, known_certs: _ca_bundle_cache)) {
return ScepResult<PkiMessage>.Fail(ScepClientResult.CryptoError, decode_error);
}
return ScepResult<PkiMessage>.Ok(decoded);
Expand All @@ -634,7 +639,7 @@ private async Task<ScepResult<PkiMessage>> SendDecodedAsync(PkiMessage message)
if (!raw.IsOk) {
return ScepResult<PkiMessage>.Fail(raw.Status, raw.Error);
}
if (!PkiMessage.Decode(Crypto, raw.Value, message.SignerKey!, CodecOptions.LenientParsing, out decoded, out decode_error)) {
if (!PkiMessage.Decode(Crypto, raw.Value, message.SignerKey!, CodecOptions.LenientParsing, out decoded, out decode_error, known_certs: _ca_bundle_cache)) {
return ScepResult<PkiMessage>.Fail(ScepClientResult.CryptoError, decode_error);
}
return ScepResult<PkiMessage>.Ok(decoded);
Expand Down Expand Up @@ -964,7 +969,7 @@ private ScepResult<EnrollOutcome> DecodeResponse(byte[] response_bytes, IScepKey
X509Certificate2? cert;
EnrollOutcome outcome;

if (!PkiMessage.Decode(Crypto, response_bytes, recipient_key, CodecOptions.LenientParsing, out decoded, out decode_error)) {
if (!PkiMessage.Decode(Crypto, response_bytes, recipient_key, CodecOptions.LenientParsing, out decoded, out decode_error, known_certs: _ca_bundle_cache)) {
return ScepResult<EnrollOutcome>.Fail(ScepClientResult.CryptoError, decode_error);
}

Expand Down
110 changes: 96 additions & 14 deletions src/ScepWright.Crypto.BouncyCastle/BcPkiMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,22 +138,25 @@ public static byte[] BuildIssuerAndSubject(string issuer_dn, string subject_dn)
private const string Sha1DigestOid = "1.3.14.3.2.26";

public static PkiMessage Decode(byte[] der, BcKey recipient_key, CodecOptions options) {
return Decode(der, recipient_key, options, out _);
return Decode(der, recipient_key, options, known_certs: null, out _);
}

// Honors CodecOptions. Strict (0) enforces both a valid CMS signature and a non-legacy signer digest;
// SkipSignatureVerification relaxes the first gate, AllowLegacyAlgorithms the second, and
// LenientParsing relaxes both (today's tolerant behavior). On a strict-mode violation the message is
// still returned (so callers can inspect it), but decode_error is set non-empty so the provider can
// surface a clean false + error.
public static PkiMessage Decode(byte[] der, BcKey recipient_key, CodecOptions options, out string decode_error) {
public static PkiMessage Decode(byte[] der, BcKey recipient_key, CodecOptions options,
System.Collections.Generic.IReadOnlyList<System.Security.Cryptography.X509Certificates.X509Certificate2>? known_certs, out string decode_error) {
CmsSignedData signed_data;
IStore<Org.BouncyCastle.X509.X509Certificate> cert_store;
ICollection<SignerInformation> signer_collection;
System.Collections.IEnumerator signer_enumerator;
SignerInformation signer;
System.Collections.Generic.IEnumerable<Org.BouncyCastle.X509.X509Certificate> matching_certs;
Org.BouncyCastle.X509.X509Certificate? embedded_match;
Org.BouncyCastle.X509.X509Certificate signer_cert;
string verified_source;
int candidate_count;
bool signature_ok;
bool lenient;
string digest_oid;
Expand Down Expand Up @@ -182,25 +185,63 @@ public static PkiMessage Decode(byte[] der, BcKey recipient_key, CodecOptions op
signer_enumerator.MoveNext();
signer = (SignerInformation)signer_enumerator.Current;

matching_certs = cert_store.EnumerateMatches(signer.SignerID);
signer_cert = null!;
foreach (Org.BouncyCastle.X509.X509Certificate c in matching_certs) {
signer_cert = c;
// Record who the response *claims* signed it (issuer+serial or subjectKeyIdentifier), so a failed
// verification can be diagnosed: genuinely invalid vs. "the signer cert wasn't where we looked".
result.SignerClaimedIdentity = FormatSignerId(signer.SignerID);

// The cert the CertRep itself offered for the claimed signer (matched by SignerIdentifier).
embedded_match = null;
foreach (Org.BouncyCastle.X509.X509Certificate c in cert_store.EnumerateMatches(signer.SignerID)) {
embedded_match = c;
break;
}

// Verify against a candidate pool — the CertRep's own certs first, then the GetCACert bundle — so a
// valid signature whose signer cert was simply not embedded is confirmed, and a "claimed cert X but
// cert Y actually signed" mismatch is detected rather than reported as a bare failure.
signature_ok = false;
if (signer_cert != null) {
try {
signature_ok = signer.Verify(signer_cert);
} catch {
signature_ok = false;
signer_cert = null!;
verified_source = string.Empty;
candidate_count = 0;
if (embedded_match != null && TryVerify(signer, embedded_match)) {
signature_ok = true;
signer_cert = embedded_match;
verified_source = "CertRep";
} else {
foreach (System.ValueTuple<Org.BouncyCastle.X509.X509Certificate, string> candidate in VerificationCandidates(cert_store, known_certs)) {
candidate_count++;
if (!signature_ok && TryVerify(signer, candidate.Item1)) {
signature_ok = true;
signer_cert = candidate.Item1;
verified_source = candidate.Item2;
}
}
}

result.SignatureValid = signature_ok;
if (!signature_ok) {
result.ConformanceNotes.Add(new ConformanceNote(NoteSeverity.Warning, "signature verification failed", "SignedData", "RFC 8894 §3.2"));
if (signature_ok && (verified_source != "CertRep" || !ReferenceEquals(signer_cert, embedded_match))) {
// Verified, but not by the cert the CertRep presented for the claimed signer — surface what
// actually signed, since a peer relying on the CertRep's own bag would call this invalid.
result.SignerVerifiedWith = $"{DescribeCert(signer_cert)} (from {verified_source})";
result.ConformanceNotes.Add(new ConformanceNote(NoteSeverity.Warning,
$"signature is VALID but was verified using the {verified_source} cert [{DescribeCert(signer_cert)}], not the cert the CertRep presented for the claimed signer ({result.SignerClaimedIdentity})"
+ (embedded_match == null
? " — the CertRep embedded no cert matching the claimed signer; the server should include its RA/CA signing cert in the CertRep"
: $" — the embedded cert [{DescribeCert(embedded_match)}] did not verify the signature"),
"SignedData", "RFC 8894 §3.2"));
} else if (signature_ok) {
result.SignerVerifiedWith = $"{DescribeCert(signer_cert)} (from CertRep)";
} else {
// Nothing verified: report the claimed signer, the cert we checked, and how many we tried, so a
// server-implementor can tell a truly bad signature from a wrong-cert / missing-cert situation.
result.SignerVerifiedWith = null;
result.ConformanceNotes.Add(new ConformanceNote(NoteSeverity.Warning,
$"signature verification FAILED — claimed signer: {result.SignerClaimedIdentity}; "
+ (embedded_match != null
? $"the cert the CertRep presented for that signer [{DescribeCert(embedded_match)}] did not verify; "
: "no cert embedded in the CertRep matched the claimed signer; ")
+ $"tried {candidate_count} candidate cert(s) from the CertRep bag and the GetCACert bundle and none produced a valid signature — the signature is invalid against every available cert (wrong signing key, altered message, or the real signing cert was provided by neither GetCACert nor the CertRep)",
"SignedData", "RFC 8894 §3.2"));
}

// Strict-mode gate 1 — signature integrity. Fail unless the caller opted into tolerance
Expand Down Expand Up @@ -307,6 +348,47 @@ private static List<X509Certificate2> ExtractCertsFromDegeneratePkcs7(byte[] der
return certs;
}

private static bool TryVerify(SignerInformation signer, Org.BouncyCastle.X509.X509Certificate cert) {
try {
return signer.Verify(cert);
} catch {
return false;
}
}

// The candidate certificates the response signature is checked against: every cert embedded in the
// CertRep, then the caller-supplied GetCACert bundle (so a signer cert the server didn't embed is found).
private static System.Collections.Generic.IEnumerable<System.ValueTuple<Org.BouncyCastle.X509.X509Certificate, string>> VerificationCandidates(
IStore<Org.BouncyCastle.X509.X509Certificate> embedded,
System.Collections.Generic.IReadOnlyList<System.Security.Cryptography.X509Certificates.X509Certificate2>? known_certs) {
Org.BouncyCastle.X509.X509CertificateParser parser;

foreach (Org.BouncyCastle.X509.X509Certificate c in embedded.EnumerateMatches(new Org.BouncyCastle.X509.Store.X509CertStoreSelector())) {
yield return new System.ValueTuple<Org.BouncyCastle.X509.X509Certificate, string>(c, "CertRep");
}
if (known_certs != null) {
parser = new Org.BouncyCastle.X509.X509CertificateParser();
foreach (System.Security.Cryptography.X509Certificates.X509Certificate2 kc in known_certs) {
yield return new System.ValueTuple<Org.BouncyCastle.X509.X509Certificate, string>(parser.ReadCertificate(kc.RawData), "GetCACert");
}
}
}

// "issuer 'CN=..', serial 0A" for an issuerAndSerialNumber signer, or "subjectKeyIdentifier <hex>".
private static string FormatSignerId(Org.BouncyCastle.Cms.SignerID id) {
if (id.Issuer != null && id.SerialNumber != null) {
return $"issuer '{id.Issuer}', serial {id.SerialNumber.ToString(16)}";
}
if (id.SubjectKeyIdentifier != null) {
return $"subjectKeyIdentifier {Org.BouncyCastle.Utilities.Encoders.Hex.ToHexString(id.SubjectKeyIdentifier)}";
}
return "(unspecified signer identifier)";
}

private static string DescribeCert(Org.BouncyCastle.X509.X509Certificate cert) {
return $"subject '{cert.SubjectDN}', serial {cert.SerialNumber.ToString(16)}";
}

private static IReadOnlyList<byte[]> ExtractCrlsFromDegeneratePkcs7(byte[] der) {
CmsSignedData signed_data;
IStore<Org.BouncyCastle.X509.X509Crl> crl_store;
Expand Down
4 changes: 2 additions & 2 deletions src/ScepWright.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ public bool EncodePkiMessage(PkiMessage message, FaultDirectives? faults, out by
}

/// <inheritdoc/>
public bool DecodePkiMessage(byte[] der, IScepKey recipient_key, CodecOptions options, out PkiMessage message, out string error) {
public bool DecodePkiMessage(byte[] der, IScepKey recipient_key, CodecOptions options, System.Collections.Generic.IReadOnlyList<System.Security.Cryptography.X509Certificates.X509Certificate2>? known_certs, out PkiMessage message, out string error) {
message = null!;
error = string.Empty;

Expand All @@ -191,7 +191,7 @@ public bool DecodePkiMessage(byte[] der, IScepKey recipient_key, CodecOptions op
try {
string decode_error;

message = BcPkiMessage.Decode(der, bc_key, options, out decode_error);
message = BcPkiMessage.Decode(der, bc_key, options, known_certs, out decode_error);
if (decode_error.Length > 0) {
error = decode_error;
return false;
Expand Down
9 changes: 7 additions & 2 deletions src/ScepWright.Crypto/IScepCrypto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ public interface IScepCrypto {
/// <summary>Encodes a SCEP PKI message to DER, optionally injecting the given fault directives for negative testing.</summary>
bool EncodePkiMessage(PkiMessage message, FaultDirectives? faults, out byte[] der, out string error);

/// <summary>Decodes a SCEP PKI message from DER, decrypting with the recipient key and applying the given codec options.</summary>
bool DecodePkiMessage(byte[] der, IScepKey recipient_key, CodecOptions options, out PkiMessage message, out string error);
/// <summary>
/// Decodes a SCEP PKI message from DER, decrypting with the recipient key and applying the given codec
/// options. <paramref name="known_certs"/> (e.g. the GetCACert bundle) are added to the pool of
/// certificates the response signature is verified against, so a valid signature whose signer cert was
/// not embedded in the message can still be confirmed and diagnosed.
/// </summary>
bool DecodePkiMessage(byte[] der, IScepKey recipient_key, CodecOptions options, IReadOnlyList<X509Certificate2>? known_certs, out PkiMessage message, out string error);

/// <summary>Parses a CA certificate bundle (degenerate PKCS#7 or raw cert) from DER.</summary>
bool ParseCaCertificates(byte[] der, out IReadOnlyList<X509Certificate2> certs, out string error);
Expand Down
21 changes: 18 additions & 3 deletions src/ScepWright.Crypto/PkiMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ public sealed class PkiMessage {
public byte[]? RecipientNonce { get; set; }
/// <summary>Gets or sets whether the outer signature verified (decode output).</summary>
public bool SignatureValid { get; set; }
/// <summary>
/// Gets or sets the signer identity the response *claimed* (the CMS SignerIdentifier — issuer+serial or
/// subjectKeyIdentifier), so a diagnostic can compare who signed against which cert was checked (decode output).
/// </summary>
public string? SignerClaimedIdentity { get; set; }
/// <summary>
/// Gets or sets a description of the certificate whose public key actually verified the signature, and
/// where it came from (the CertRep's own bag or the GetCACert bundle), or null if none verified (decode output).
/// </summary>
public string? SignerVerifiedWith { get; set; }
/// <summary>Gets or sets the decrypted inner content (decode output).</summary>
public byte[]? DecryptedContent { get; set; }
/// <summary>Gets or sets the certificates returned in a successful CertRep (decode output).</summary>
Expand All @@ -68,7 +78,12 @@ public bool Encode(IScepCrypto crypto, FaultDirectives? faults, out byte[] der,
return crypto.EncodePkiMessage(this, faults, out der, out error);
}

/// <summary>Decodes a SCEP PKI message from DER, decrypting with the given recipient key.</summary>
public static bool Decode(IScepCrypto crypto, byte[] der, IScepKey key, CodecOptions options, out PkiMessage message, out string error) =>
crypto.DecodePkiMessage(der, key, options, out message, out error);
/// <summary>
/// Decodes a SCEP PKI message from DER, decrypting with the given recipient key. Pass
/// <paramref name="known_certs"/> (e.g. the GetCACert bundle) so a response whose signer cert is not
/// embedded can still have its signature verified and diagnosed.
/// </summary>
public static bool Decode(IScepCrypto crypto, byte[] der, IScepKey key, CodecOptions options, out PkiMessage message, out string error,
IReadOnlyList<X509Certificate2>? known_certs = null) =>
crypto.DecodePkiMessage(der, key, options, known_certs, out message, out error);
}
2 changes: 1 addition & 1 deletion tests/ScepWright.Tests/BcCrlDecodeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public void Decodes_crl_from_certrep() {

rep = ca.BuildSuccessCrlRep(ca.GenerateCrl(), recipient_cert, "tx", new byte[16]);

Assert.True(crypto.DecodePkiMessage(rep, recipient_key, CodecOptions.LenientParsing, out decoded, out error), error);
Assert.True(crypto.DecodePkiMessage(rep, recipient_key, CodecOptions.LenientParsing, null, out decoded, out error), error);
Assert.Single(decoded.IssuedCrls);
parsed = new Org.BouncyCastle.X509.X509CrlParser().ReadCrl(decoded.IssuedCrls[0]);
Assert.NotNull(parsed);
Expand Down
2 changes: 1 addition & 1 deletion tests/ScepWright.Tests/BcDecodeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public void Decodes_success_certrep_with_issued_cert() {

cert_rep = ca.BuildSuccessCertRep(issued, client_cert, "abc123", new byte[16]);

Assert.True(crypto.DecodePkiMessage(cert_rep, key, CodecOptions.LenientParsing, out decoded, out error), error);
Assert.True(crypto.DecodePkiMessage(cert_rep, key, CodecOptions.LenientParsing, null, out decoded, out error), error);
Assert.Equal(PkiStatus.Success, decoded.PkiStatus);
Assert.True(decoded.SignatureValid);
Assert.Single(decoded.IssuedCerts);
Expand Down
Loading