diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs index 9259a552..d1bc2f08 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs @@ -14,7 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal /// protections cover it. /// -public sealed class OpcUaClientDriverOptions +// A record (not a plain class) so G-2b's OpcUaClientSecretResolution can produce a +// credential-resolved copy with a `with` expression — every property keeps its init setter. +public sealed record OpcUaClientDriverOptions { /// /// Remote OPC UA endpoint URL, e.g. opc.tcp://plc.internal:4840. Convenience diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/NullSecretResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/NullSecretResolver.cs new file mode 100644 index 00000000..2ab68889 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/NullSecretResolver.cs @@ -0,0 +1,26 @@ +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// A null-object that resolves every name to null +/// (absent). Backs the driver's default (test/parse-only) construction path so callers that +/// never author a secret: credential ref need not thread a real resolver. A production +/// OpcUaClientDriver always receives the DI-registered resolver via the factory; a +/// secret: ref resolved against this null object throws fail-closed (the secret is +/// reported absent), which is the correct behaviour for a mis-wired deployment — the +/// secret: literal is never sent verbatim as a password. +/// +internal sealed class NullSecretResolver : ISecretResolver +{ + /// The shared singleton instance. + public static readonly NullSecretResolver Instance = new(); + + private NullSecretResolver() + { + } + + /// + public Task GetAsync(SecretName name, CancellationToken ct) => + Task.FromResult(null); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 1df13d30..d9ac8555 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -4,6 +4,7 @@ using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; @@ -36,15 +37,25 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit /// Driver configuration. /// Stable logical ID from the config DB. /// Optional logger; defaults to NullLogger when not supplied. + /// + /// Optional shared secret resolver used to resolve secret:-prefixed + /// / + /// at session-open. + /// Defaults to the null-object (which reports every + /// secret absent) for the test/parse-only path; the production factory threads the + /// DI-registered resolver. A secret: ref against the null-object fails closed. + /// public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId, - ILogger? logger = null) + ILogger? logger = null, ISecretResolver? secretResolver = null) { _options = options; _driverInstanceId = driverInstanceId; _logger = logger ?? NullLogger.Instance; + _secretResolver = secretResolver ?? NullSecretResolver.Instance; } private readonly OpcUaClientDriverOptions _options; + private readonly ISecretResolver _secretResolver; private readonly string _driverInstanceId; // ---- IAlarmSource state ---- @@ -159,7 +170,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false); var candidates = ResolveEndpointCandidates(_options); - var identity = BuildUserIdentity(_options); + // G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the + // shared secret store ONCE, here in the async connect flow, just before the credentials + // are consumed. _options stays the raw config (with secret: refs) — only this + // connect-scoped local carries plaintext, and only for the duration of the connect. + // Resolving on every InitializeAsync (a full reconnect calls it) picks up rotations. + // Both credential consumers — the Username password below and the Certificate password + // in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a + // single resolve covers both paths. + var resolvedOptions = await OpcUaClientSecretResolution + .ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false); + + var identity = BuildUserIdentity(resolvedOptions); // Failover sweep: try each endpoint in order, return the session from the first // one that successfully connects. Per-endpoint failures are captured so the final diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs index ad47daff..9da3a22f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; @@ -31,22 +32,37 @@ public static class OpcUaClientDriverFactoryExtensions Converters = { new JsonStringEnumConverter() }, }; - /// Register the OpcUaClient factory with the driver registry. + /// Register the OpcUaClient factory with the driver registry, threading the shared secret resolver. /// The driver factory registry to register with. /// Optional logger factory used to create per-instance loggers. - public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) + /// + /// The shared secret resolver (from DI) used to resolve the secret: arm of each + /// instance's Password / UserCertificatePassword at session-open. Defaults to + /// the null-object for the test/standalone path — callers + /// that need a working secret: credential ref must thread the DI resolver. + /// + public static void Register( + DriverFactoryRegistry registry, + ILoggerFactory? loggerFactory = null, + ISecretResolver? secretResolver = null) { ArgumentNullException.ThrowIfNull(registry); - registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); + var resolver = secretResolver ?? NullSecretResolver.Instance; + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, resolver)); } /// Public for the Server-side bootstrapper + test consumers. /// The unique identifier for the driver instance. /// The JSON configuration string for the driver. /// Optional logger factory for the per-instance logger. + /// + /// Optional shared secret resolver for the driver's secret: credential arm; defaults + /// to the null-object (fail-closed on a secret: ref). + /// /// A configured . public static OpcUaClientDriver CreateInstance( - string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null) + string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null, + ISecretResolver? secretResolver = null) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); @@ -55,6 +71,8 @@ public static class OpcUaClientDriverFactoryExtensions ?? throw new InvalidOperationException( $"OpcUaClient driver config for '{driverInstanceId}' deserialised to null"); - return new OpcUaClientDriver(options, driverInstanceId, loggerFactory?.CreateLogger()); + return new OpcUaClientDriver( + options, driverInstanceId, loggerFactory?.CreateLogger(), + secretResolver ?? NullSecretResolver.Instance); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs index 627c8e1b..748ff0c3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs @@ -34,6 +34,12 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { + // G-2b note: the probe does an UNAUTHENTICATED GetEndpoints discovery preflight + // (BuildMinimalAppConfig sets no user identity) and never reads opts.Password / + // opts.UserCertificatePassword. Those credentials — including any secret: refs — are + // therefore intentionally NOT resolved here; resolving them would be dead code. Secret + // resolution happens lazily in OpcUaClientDriver's async session-open path, where the + // credentials are actually consumed. OpcUaClientDriverOptions? opts; try { opts = JsonSerializer.Deserialize(configJson, _opts); } catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs new file mode 100644 index 00000000..07a9dd0d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs @@ -0,0 +1,90 @@ +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// Layer-B (G-2b) secret resolution for the OPC UA Client driver's credential fields. Resolves +/// secret:-prefixed and +/// through the shared +/// (the encrypted-at-rest store) just before the async +/// session-open path consumes them — retiring the cleartext password-in-DB model for +/// production. +/// +/// +/// Resolution is lazy (called from the connect flow, not at config deserialization) so a +/// full reconnect via InitializeAsync re-resolves and picks up secret rotations, +/// mirroring the Galaxy driver's re-resolve-on-reconnect behaviour. Only secret:-prefixed +/// values are resolved; a null/empty or non-secret: value passes through verbatim so the +/// literal-password back-compat path is preserved. The secret: arm is fail-closed: +/// an absent/tombstoned secret throws rather than leaving the secret: literal in place, +/// which would otherwise be sent verbatim to the remote server as the password. +/// +internal static class OpcUaClientSecretResolution +{ + private const string SecretPrefix = "secret:"; + + /// + /// Return a copy of with any secret:-prefixed + /// / + /// resolved to their + /// plaintext via . Non-secret / null / empty fields are + /// returned unchanged. + /// + /// The raw driver options (credential fields may carry secret: refs). + /// The shared secret resolver used by the secret: arm. + /// Cancellation token for the async secret resolution. + /// An options copy whose credential fields carry resolved plaintext. + /// + /// A secret: ref names a secret that is absent/tombstoned in the store (fail-closed). + /// + internal static async Task ResolveSecretRefsAsync( + OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resolver); + + var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct) + .ConfigureAwait(false); + var certPassword = await ResolveFieldAsync( + options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct) + .ConfigureAwait(false); + + // Only re-materialize when something actually changed — a `with` on the reference-equal + // strings is harmless, but skipping it keeps the common (no-secret) case allocation-free. + if (ReferenceEquals(password, options.Password) + && ReferenceEquals(certPassword, options.UserCertificatePassword)) + { + return options; + } + + return options with { Password = password, UserCertificatePassword = certPassword }; + } + + /// + /// Resolve a single credential field. Null/empty or non-secret: values pass through + /// unchanged (the reference-equal original is returned). A secret:NAME value is + /// resolved through and is fail-closed when the secret is absent. + /// + /// The raw field value (may be a secret: ref). + /// The field name, used in the fail-closed exception message. + /// The shared secret resolver. + /// Cancellation token for the async resolution. + /// The resolved plaintext, or the original value when it is not a secret: ref. + private static async Task ResolveFieldAsync( + string? value, string fieldName, ISecretResolver resolver, CancellationToken ct) + { + if (string.IsNullOrEmpty(value) + || !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase)) + { + return value; + } + + var name = value[SecretPrefix.Length..]; + var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); + return !string.IsNullOrEmpty(resolved) + ? resolved + : throw new InvalidOperationException( + $"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " + + "absent from the store (fail-closed)."); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 9fcb27fe..71b5d0b7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -144,7 +144,7 @@ public static class DriverFactoryBootstrap Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry); Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory); Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory); - Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory); + Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver); Driver.S7.S7DriverFactoryExtensions.Register(registry); Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs new file mode 100644 index 00000000..642ede3d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs @@ -0,0 +1,95 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// G-2b — pins , the Layer-B +/// arm that resolves secret:-prefixed +/// and through the shared +/// just before the async session-open consumes them. The +/// secret: arm is fail-closed — an absent secret throws rather than leaving the +/// secret: literal in place (which would be sent verbatim as the password). +/// Non-secret literals and null/empty pass through unchanged. +/// +public sealed class OpcUaClientSecretResolutionTests +{ + private const string KnownPwName = "opcua/inst/password"; + private const string KnownPwValue = "resolved-password-plaintext"; + private const string KnownCertPwName = "opcua/inst/cert-pw"; + private const string KnownCertPwValue = "resolved-cert-pw-plaintext"; + + /// A fake resolver: returns known values for two known names, null otherwise. + private static ISecretResolver FakeResolver() => new StubSecretResolver(); + + /// (a) A secret: Password resolves to the store's plaintext. + [Fact] + public async Task Secret_password_resolves_to_store_plaintext() + { + var options = new OpcUaClientDriverOptions { Password = $"secret:{KnownPwName}" }; + + var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync( + options, FakeResolver(), CancellationToken.None); + + resolved.Password.ShouldBe(KnownPwValue); + } + + /// (b) A secret: UserCertificatePassword resolves to the store's plaintext. + [Fact] + public async Task Secret_cert_password_resolves_to_store_plaintext() + { + var options = new OpcUaClientDriverOptions { UserCertificatePassword = $"secret:{KnownCertPwName}" }; + + var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync( + options, FakeResolver(), CancellationToken.None); + + resolved.UserCertificatePassword.ShouldBe(KnownCertPwValue); + } + + /// (c) A non-secret literal Password and a null field pass through unchanged. + [Fact] + public async Task Literal_and_null_pass_through_unchanged() + { + var options = new OpcUaClientDriverOptions + { + Password = "plainpw", + UserCertificatePassword = null, + }; + + var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync( + options, FakeResolver(), CancellationToken.None); + + resolved.Password.ShouldBe("plainpw"); + resolved.UserCertificatePassword.ShouldBeNull(); + } + + /// (d) An unknown secret: ref (resolver returns null) is fail-closed — it throws + /// and never leaves the secret: literal in place. + [Fact] + public async Task Unknown_secret_ref_is_fail_closed() + { + var options = new OpcUaClientDriverOptions { Password = "secret:opcua/inst/absent" }; + + var ex = await Should.ThrowAsync(() => + OpcUaClientSecretResolution.ResolveSecretRefsAsync( + options, FakeResolver(), CancellationToken.None)); + + ex.Message.ShouldContain("Password"); + ex.Message.ShouldContain("opcua/inst/absent"); + } + + /// A fake resolver: returns known values for two known names, null otherwise. + private sealed class StubSecretResolver : ISecretResolver + { + /// + public Task GetAsync(SecretName name, CancellationToken ct) => + Task.FromResult(name.Value switch + { + KnownPwName => KnownPwValue, + KnownCertPwName => KnownCertPwValue, + _ => null, + }); + } +}