fix(client): use default certificate lifetime without rotating persisted keys

This commit is contained in:
Joseph Doherty
2026-11-10 12:26:54 -05:00
parent 2d75cecbb9
commit bd3143b7d7
2 changed files with 61 additions and 1 deletions
@@ -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
@@ -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;
/// <summary>Uses the real configuration factory so fake sessions cannot conceal SDK initialization failures.</summary>
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)]