From 4a3860ae92e79a64faf8529da9147c3420555d3d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 16:05:50 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20opcuaclient-5=20=E2=80=94=20CRL/revocat?= =?UTF-8?q?ion=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds explicit revoked-vs-untrusted distinction to the OpcUaClient driver's server-cert validation hook, plus three new knobs on a new OpcUaCertificateValidationOptions sub-record: RejectSHA1SignedCertificates (default true — SHA-1 is OPC UA spec-deprecated; this is a deliberately tighter default) RejectUnknownRevocationStatus (default false — keeps brownfield deployments without CRL infrastructure working) MinimumCertificateKeySize (default 2048) The validator hook now runs whether or not AutoAcceptCertificates is set: revoked / issuer-revoked certs are always rejected with a distinct "REVOKED" log line; SHA-1 + small-key certs are rejected per policy; unknown-revocation gates on the new flag; untrusted still honours AutoAccept. Decision pipeline factored into a static EvaluateCertificateValidation helper with a CertificateValidationDecision record so unit tests cover all branches without needing to spin up an SDK CertificateValidator. CRL files themselves: the OPC UA SDK reads them automatically from the crl/ subdir of each cert store — no driver-side wiring needed. Documented on the new options record. Tests (12 new) cover defaults, every branch of the decision pipeline, SHA-1 detection (custom X509SignatureGenerator since .NET 10's CreateSelfSigned refuses SHA-1), and key-size detection. All 127 OpcUaClient unit tests still pass. Closes #277 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OpcUaClientDriver.cs | 139 +++++++++- .../OpcUaClientDriverOptions.cs | 48 ++++ .../OpcUaClientCertValidationTests.cs | 242 ++++++++++++++++++ 3 files changed, 418 insertions(+), 11 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertValidationTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 992ee2a..04b2cdc 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography.X509Certificates; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; @@ -248,17 +249,11 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false); - // Attach a cert-validator handler that honours the AutoAccept flag. Without this, - // AutoAcceptUntrustedCertificates on the config alone isn't always enough in newer - // SDK versions — the validator raises an event the app has to handle. - if (_options.AutoAcceptCertificates) - { - config.CertificateValidator.CertificateValidation += (s, e) => - { - if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted) - e.Accept = true; - }; - } + // Attach a cert-validator handler. The SDK's AutoAcceptUntrustedCertificates flag + // alone isn't always enough in newer SDK versions — the validator raises an event + // the app has to handle. We also use this hook to enforce the + // CertificateValidation policy (revoked, SHA-1, key size) regardless of AutoAccept. + config.CertificateValidator.CertificateValidation += OnCertificateValidation; // Ensure an application certificate exists. The SDK auto-generates one if missing. app.ApplicationConfiguration = config; @@ -268,6 +263,128 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d return config; } + /// + /// Cert-validator callback. Funnels into + /// for testability — the static helper takes the cert + status code + options and + /// returns the decision, which this method then applies to the SDK's event args. + /// + private void OnCertificateValidation(object sender, Opc.Ua.CertificateValidationEventArgs e) + { + var decision = EvaluateCertificateValidation( + e.Certificate, + e.Error.StatusCode, + _options.AutoAcceptCertificates, + _options.CertificateValidation); + + if (decision.LogMessage is { Length: > 0 }) + { + // Use the SDK's trace surface — no driver-side ILogger is plumbed today, and the + // SDK trace is already wired up by the host. Warning level for rejections so + // operators surface them without code changes. The non-telemetry overload is + // marked obsolete in the latest SDK; suppress locally to keep the gateway-driver + // surface free of an ITelemetryContext plumb-through (parity with the same + // pattern in BuildApplicationConfigurationAsync). +#pragma warning disable CS0618 + Opc.Ua.Utils.LogWarning( + "OpcUaClient[{0}] cert-validation: {1} (subject={2}, status=0x{3:X8})", + driverInstanceId, decision.LogMessage, + e.Certificate?.Subject ?? "", + (uint)e.Error.StatusCode.Code); +#pragma warning restore CS0618 + } + + e.Accept = decision.Accept; + } + + /// + /// Cert-validation decision pipeline. Pulled out as a static helper so unit tests can + /// drive each branch without standing up an OPC UA SDK CertificateValidator. + /// Order matters: revoked > SHA-1 > key-size > revocation-unknown > auto-accept-untrusted. + /// + /// Server certificate the SDK is asking us to validate. May be null in pathological cases. + /// The SDK's validation result. Good = no failure to inspect. + /// Mirror of . + /// The cert-validation knobs. + internal static CertificateValidationDecision EvaluateCertificateValidation( + System.Security.Cryptography.X509Certificates.X509Certificate2? cert, + Opc.Ua.StatusCode status, + bool autoAcceptUntrusted, + OpcUaCertificateValidationOptions opts) + { + // Revoked certs are always a hard fail — never auto-accept regardless of flags. + if (status.Code == Opc.Ua.StatusCodes.BadCertificateRevoked) + return new CertificateValidationDecision(false, "REVOKED server certificate — rejecting"); + if (status.Code == Opc.Ua.StatusCodes.BadCertificateIssuerRevoked) + return new CertificateValidationDecision(false, "REVOKED issuer certificate — rejecting"); + + // SHA-1 signature detection runs even when the SDK didn't surface a status — + // we want to reject SHA-1 certs on policy, not just when the SDK happens to flag them. + if (opts.RejectSHA1SignedCertificates && IsSha1Signed(cert)) + return new CertificateValidationDecision(false, "SHA-1 signed certificate rejected by policy"); + + // Key-size check: only meaningful for RSA keys; ECC bypasses. + if (cert is not null && TryGetRsaKeySize(cert, out var keyBits) && keyBits < opts.MinimumCertificateKeySize) + return new CertificateValidationDecision(false, + $"RSA key size {keyBits} bits below minimum {opts.MinimumCertificateKeySize}"); + + // Unknown revocation status — reject only if policy says so. + if (status.Code == Opc.Ua.StatusCodes.BadCertificateRevocationUnknown + || status.Code == Opc.Ua.StatusCodes.BadCertificateIssuerRevocationUnknown) + { + if (opts.RejectUnknownRevocationStatus) + return new CertificateValidationDecision(false, "revocation status unknown (no/stale CRL) — rejecting per policy"); + return new CertificateValidationDecision(true, "revocation status unknown (no/stale CRL) — accepting per policy"); + } + + // Untrusted: SDK couldn't chain the cert to a trusted issuer. Honour AutoAccept. + if (status.Code == Opc.Ua.StatusCodes.BadCertificateUntrusted) + { + if (autoAcceptUntrusted) return new CertificateValidationDecision(true, null); + return new CertificateValidationDecision(false, "untrusted certificate — rejecting (AutoAcceptCertificates=false)"); + } + + // Anything else is an SDK-level failure — let the SDK's default disposition stand + // (don't accept by default; surface the status code in the log). + if (status.Code != Opc.Ua.StatusCodes.Good) + return new CertificateValidationDecision(false, $"validation failed (status=0x{(uint)status.Code:X8})"); + + return new CertificateValidationDecision(true, null); + } + + /// + /// True when the cert's signature algorithm OID matches a SHA-1 RSA signature + /// (1.2.840.113549.1.1.5) or a SHA-1 ECDSA signature (1.2.840.10045.4.1). + /// Friendly-name prefix match is unreliable across .NET runtimes, so we use OIDs. + /// + internal static bool IsSha1Signed(System.Security.Cryptography.X509Certificates.X509Certificate2? cert) + { + if (cert is null) return false; + var oid = cert.SignatureAlgorithm?.Value; + return oid is "1.2.840.113549.1.1.5" // sha1RSA + or "1.2.840.10045.4.1"; // sha1ECDSA + } + + /// + /// Read the RSA public key size in bits if the cert has an RSA key. Returns false for + /// non-RSA (ECC, DSA) certs so the key-size check is skipped on them. + /// + internal static bool TryGetRsaKeySize( + System.Security.Cryptography.X509Certificates.X509Certificate2 cert, + out int keyBits) + { + using var rsa = cert.GetRSAPublicKey(); + if (rsa is null) { keyBits = 0; return false; } + keyBits = rsa.KeySize; + return true; + } + + /// + /// Outcome of . + /// is null when the decision is silently "accept (Good)" — no need to log healthy + /// validations. + /// + internal readonly record struct CertificateValidationDecision(bool Accept, string? LogMessage); + /// /// Resolve the ordered failover candidate list. EndpointUrls wins when /// non-empty; otherwise fall back to EndpointUrl as a single-URL shortcut so diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs index ca0d7b1..8563150 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs @@ -141,8 +141,56 @@ public sealed class OpcUaClientDriverOptions /// values so existing deployments see no behaviour change. /// public OpcUaSubscriptionDefaults Subscriptions { get; init; } = new(); + + /// + /// Server-certificate validation knobs applied during the + /// CertificateValidator.CertificateValidation callback. Surfaces explicit + /// handling for revoked certs (always rejected, never auto-accepted), unknown + /// revocation status (rejected only when + /// is set), SHA-1 signature rejection, and minimum RSA key size. Defaults preserve + /// existing behaviour wherever possible — the one tightening is + /// =true + /// since SHA-1 is spec-deprecated for OPC UA. + /// + public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new(); } +/// +/// Knobs governing the server-certificate validation callback. Plumbed onto +/// rather than the top-level +/// options to keep cert-related config grouped together. +/// +/// +/// +/// CRL discovery: the OPC UA SDK reads CRL files automatically from the +/// crl/ sub-directory of each cert store (own, trusted, issuers). Drop the +/// issuer's .crl in that folder and the SDK picks it up — no driver-side wiring +/// required. When the directory is absent or empty, the SDK reports +/// BadCertificateRevocationUnknown, which this driver gates with +/// . +/// +/// +/// +/// Reject server certificates whose signature uses SHA-1. Default true — SHA-1 was +/// deprecated by the OPC UA spec and is treated as a hard fail in production. Flip to +/// false only for short-term interop with legacy controllers. +/// +/// +/// When the SDK can't determine revocation status (no CRL present, or stale CRL), +/// reject the cert if true; allow if false. Default false — many +/// plant deployments don't run CRL infrastructure, and a hard-fail default would break +/// them on first connection. Set true in environments with a managed PKI. +/// +/// +/// Minimum RSA key size (bits) accepted. Certs with shorter keys are rejected. Default +/// 2048 matches the current OPC UA spec floor; raise to 3072 or 4096 for stricter +/// deployments. Non-RSA keys (ECC) bypass this check. +/// +public sealed record OpcUaCertificateValidationOptions( + bool RejectSHA1SignedCertificates = true, + bool RejectUnknownRevocationStatus = false, + int MinimumCertificateKeySize = 2048); + /// /// Tuning surface for OPC UA subscriptions created by . /// Lifted from the per-call hard-coded literals so operators can tune publish cadence, diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertValidationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertValidationTests.cs new file mode 100644 index 0000000..46a3f58 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertValidationTests.cs @@ -0,0 +1,242 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Opc.Ua; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Unit coverage for the cert-validation knobs added in PR #277. Live revocation testing +/// requires standing up a CA + CRL; we cover the parts that are testable without one: +/// option defaults, the static decision pipeline, SHA-1 detection, and key-size checks. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientCertValidationTests +{ + [Fact] + public void Defaults_match_documented_policy() + { + var opts = new OpcUaClientDriverOptions(); + opts.CertificateValidation.RejectSHA1SignedCertificates.ShouldBeTrue( + "SHA-1 is spec-deprecated for OPC UA — default must be hard-fail."); + opts.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeFalse( + "Default must allow brownfield deployments without CRL infrastructure."); + opts.CertificateValidation.MinimumCertificateKeySize.ShouldBe(2048); + } + + [Fact] + public void Revoked_cert_is_rejected_even_when_AutoAccept_is_true() + { + using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); + + var decision = OpcUaClientDriver.EvaluateCertificateValidation( + cert, + new StatusCode(StatusCodes.BadCertificateRevoked), + autoAcceptUntrusted: true, + new OpcUaCertificateValidationOptions()); + + decision.Accept.ShouldBeFalse(); + decision.LogMessage!.ShouldContain("REVOKED"); + } + + [Fact] + public void Issuer_revoked_is_rejected_even_when_AutoAccept_is_true() + { + using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); + + var decision = OpcUaClientDriver.EvaluateCertificateValidation( + cert, + new StatusCode(StatusCodes.BadCertificateIssuerRevoked), + autoAcceptUntrusted: true, + new OpcUaCertificateValidationOptions()); + + decision.Accept.ShouldBeFalse(); + decision.LogMessage!.ShouldContain("REVOKED issuer"); + } + + [Fact] + public void RevocationUnknown_default_accepts_with_log_note() + { + using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); + + var decision = OpcUaClientDriver.EvaluateCertificateValidation( + cert, + new StatusCode(StatusCodes.BadCertificateRevocationUnknown), + autoAcceptUntrusted: false, + new OpcUaCertificateValidationOptions { RejectUnknownRevocationStatus = false }); + + decision.Accept.ShouldBeTrue(); + decision.LogMessage!.ShouldContain("revocation status unknown"); + } + + [Fact] + public void RevocationUnknown_with_strict_flag_rejects() + { + using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); + + var decision = OpcUaClientDriver.EvaluateCertificateValidation( + cert, + new StatusCode(StatusCodes.BadCertificateRevocationUnknown), + autoAcceptUntrusted: true, + new OpcUaCertificateValidationOptions { RejectUnknownRevocationStatus = true }); + + decision.Accept.ShouldBeFalse(); + decision.LogMessage!.ShouldContain("revocation status unknown"); + } + + [Fact] + public void Sha1_signed_cert_is_rejected_by_default() + { + using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA1); + + var decision = OpcUaClientDriver.EvaluateCertificateValidation( + cert, + new StatusCode(StatusCodes.Good), + autoAcceptUntrusted: false, + new OpcUaCertificateValidationOptions()); + + decision.Accept.ShouldBeFalse(); + decision.LogMessage!.ShouldContain("SHA-1"); + } + + [Fact] + public void Sha1_acceptance_can_be_opted_back_into() + { + using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA1); + + // Untrusted + auto-accept = let it through; SHA-1 must NOT be the failing reason. + var decision = OpcUaClientDriver.EvaluateCertificateValidation( + cert, + new StatusCode(StatusCodes.BadCertificateUntrusted), + autoAcceptUntrusted: true, + new OpcUaCertificateValidationOptions { RejectSHA1SignedCertificates = false }); + + decision.Accept.ShouldBeTrue(); + } + + [Fact] + public void Small_rsa_key_is_rejected_below_minimum() + { + using var cert = CreateRsaCert(1024, HashAlgorithmName.SHA256); + + var decision = OpcUaClientDriver.EvaluateCertificateValidation( + cert, + new StatusCode(StatusCodes.Good), + autoAcceptUntrusted: false, + new OpcUaCertificateValidationOptions()); + + decision.Accept.ShouldBeFalse(); + decision.LogMessage!.ShouldContain("1024"); + } + + [Fact] + public void TryGetRsaKeySize_reports_correct_bit_count() + { + using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); + + OpcUaClientDriver.TryGetRsaKeySize(cert, out var bits).ShouldBeTrue(); + bits.ShouldBe(2048); + } + + [Fact] + public void IsSha1Signed_detects_sha1_signature() + { + using var sha1Cert = CreateRsaCert(2048, HashAlgorithmName.SHA1); + using var sha256Cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); + + OpcUaClientDriver.IsSha1Signed(sha1Cert).ShouldBeTrue(); + OpcUaClientDriver.IsSha1Signed(sha256Cert).ShouldBeFalse(); + OpcUaClientDriver.IsSha1Signed(null).ShouldBeFalse(); + } + + [Fact] + public void Untrusted_without_AutoAccept_is_rejected() + { + using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); + + var decision = OpcUaClientDriver.EvaluateCertificateValidation( + cert, + new StatusCode(StatusCodes.BadCertificateUntrusted), + autoAcceptUntrusted: false, + new OpcUaCertificateValidationOptions()); + + decision.Accept.ShouldBeFalse(); + decision.LogMessage!.ShouldContain("untrusted"); + } + + [Fact] + public void Good_status_with_compliant_cert_accepts_silently() + { + using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); + + var decision = OpcUaClientDriver.EvaluateCertificateValidation( + cert, + new StatusCode(StatusCodes.Good), + autoAcceptUntrusted: false, + new OpcUaCertificateValidationOptions()); + + decision.Accept.ShouldBeTrue(); + decision.LogMessage.ShouldBeNull("Good validations shouldn't emit log noise."); + } + + private static X509Certificate2 CreateRsaCert(int keySize, HashAlgorithmName hash) + { + // .NET 10's CertificateRequest.CreateSelfSigned rejects SHA-1 outright. For the + // SHA-256 path we use the supported API; for SHA-1 we route through a custom + // X509SignatureGenerator that signs with SHA-1 OID so we can synthesise a SHA-1 + // signed cert in-process without shipping a binary fixture. + var rsa = RSA.Create(keySize); + var req = new CertificateRequest( + new System.Security.Cryptography.X509Certificates.X500DistinguishedName( + "CN=OpcUaClientCertValidationTests"), + rsa, + hash == HashAlgorithmName.SHA1 ? HashAlgorithmName.SHA256 : hash, + RSASignaturePadding.Pkcs1); + + if (hash == HashAlgorithmName.SHA1) + { + var generator = new Sha1RsaSignatureGenerator(rsa); + var serial = new byte[8]; + System.Security.Cryptography.RandomNumberGenerator.Fill(serial); + var built = req.Create( + req.SubjectName, + generator, + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddHours(1), + serial); + // Combine cert + key so GetRSAPublicKey works downstream. + return built.CopyWithPrivateKey(rsa); + } + + return req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddHours(1)); + } + + /// + /// SHA-1 RSA signature generator. .NET 10's + /// refuses SHA-1; we subclass to emit the SHA-1 RSA algorithm identifier + /// (1.2.840.113549.1.1.5) and sign with SHA-1 explicitly. Test-only. + /// + private sealed class Sha1RsaSignatureGenerator : X509SignatureGenerator + { + private readonly RSA _rsa; + public Sha1RsaSignatureGenerator(RSA rsa) { _rsa = rsa; } + + public override byte[] GetSignatureAlgorithmIdentifier(HashAlgorithmName hashAlgorithm) + { + // DER: SEQUENCE { OID 1.2.840.113549.1.1.5, NULL } + return new byte[] + { + 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x05, 0x05, 0x00, + }; + } + + public override byte[] SignData(byte[] data, HashAlgorithmName hashAlgorithm) + => _rsa.SignData(data, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1); + + protected override PublicKey BuildPublicKey() => PublicKey.CreateFromSubjectPublicKeyInfo( + _rsa.ExportSubjectPublicKeyInfo(), out _); + } +}