diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs index 2722d350..107117af 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs @@ -71,7 +71,9 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi ApplicationType = ApplicationType.Client, ApplicationConfiguration = config }; - await app.CheckApplicationInstanceCertificatesAsync(false, lifeTimeInMonths: 2048, ct: ct); + // Key size is configured separately; this argument is a lifetime in months. + // Keep the SDK creation default and reuse any valid certificate already in the store. + await app.CheckApplicationInstanceCertificatesAsync(false, ct: ct); } } catch diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultApplicationConfigurationFactoryTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultApplicationConfigurationFactoryTests.cs index 5b70df63..21c576e0 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultApplicationConfigurationFactoryTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultApplicationConfigurationFactoryTests.cs @@ -1,4 +1,7 @@ using Opc.Ua; +using Opc.Ua.Configuration; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters; @@ -9,6 +12,61 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters; /// Uses the real configuration factory so fake sessions cannot conceal SDK initialization failures. public sealed class DefaultApplicationConfigurationFactoryTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task New_certificates_use_the_default_lifetime_and_existing_private_keys_are_reused(bool seedLongerCertificate) + { + var ct = TestContext.Current.CancellationToken; + var root = Path.Combine(Path.GetTempPath(), $"otopcua-certificate-lifetime-{Guid.NewGuid():N}"); + var settings = new ConnectionSettings + { + EndpointUrl = "opc.tcp://localhost:4840", CertificateStorePath = root, + SecurityMode = SecurityMode.None, AutoAcceptCertificates = false, + }; + var factory = new DefaultApplicationConfigurationFactory(); + ApplicationConfiguration? current = null; + try + { + if (seedLongerCertificate) + { + var seed = await factory.CreateAsync(settings, ct); + await using var application = new ApplicationInstance(DefaultTelemetry.Create(_ => { })) { ApplicationConfiguration = seed }; + (await application.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 60, ct)).ShouldBeTrue(); + } + settings.SecurityMode = SecurityMode.SignAndEncrypt; + current = await factory.CreateAsync(settings, ct); + var certPath = Directory.GetFiles(Path.Combine(root, "own", "certs"), "*.der").ShouldHaveSingleItem(); + var privatePath = Directory.GetFiles(Path.Combine(root, "own", "private"), "*.pfx").ShouldHaveSingleItem(); + using var certificate = X509CertificateLoader.LoadCertificateFromFile(certPath); + using var privateCertificate = X509CertificateLoader.LoadPkcs12FromFile(privatePath, null, OperatingSystem.IsMacOS() ? X509KeyStorageFlags.DefaultKeySet : X509KeyStorageFlags.EphemeralKeySet); + privateCertificate.HasPrivateKey.ShouldBeTrue(); + certificate.Thumbprint.ShouldBe(privateCertificate.Thumbprint); + using var rsa = certificate.GetRSAPublicKey(); + rsa.ShouldNotBeNull().KeySize.ShouldBeGreaterThanOrEqualTo(2048); + if (seedLongerCertificate) + certificate.NotAfter.ToUniversalTime().ShouldBeGreaterThan(DateTime.UtcNow.AddYears(4)); + else + { + certificate.NotAfter.ToUniversalTime().ShouldBeGreaterThan(DateTime.UtcNow.AddMonths(CertificateFactory.DefaultLifeTime - 1)); + certificate.NotAfter.ToUniversalTime().ShouldBeLessThan(DateTime.UtcNow.AddMonths(CertificateFactory.DefaultLifeTime).AddDays(2)); + } + var originalKeyHash = SHA256.HashData(await File.ReadAllBytesAsync(privatePath, ct)); + (current.CertificateManager as IDisposable)?.Dispose(); current = null; + current = await factory.CreateAsync(settings, ct); + using var reused = X509CertificateLoader.LoadCertificateFromFile(certPath); + reused.Thumbprint.ShouldBe(certificate.Thumbprint); + SHA256.HashData(await File.ReadAllBytesAsync(privatePath, ct)).ShouldBe(originalKeyHash, + "reinitializing with a shorter creation default must not rotate an existing certificate or private key"); + Directory.GetFiles(Path.Combine(root, "own", "certs"), "*.der").Length.ShouldBe(1); + } + finally + { + (current?.CertificateManager as IDisposable)?.Dispose(); + if (Directory.Exists(root)) Directory.Delete(root, true); + } + } + [Theory] [InlineData(SecurityMode.None)] [InlineData(SecurityMode.SignAndEncrypt)]