feat(secrets): G-2b resolve OpcUaClient secret: Password/UserCertificatePassword (Task 8)
Resolve secret:-prefixed Password + UserCertificatePassword through the shared ISecretResolver, fail-closed on absent, retiring the cleartext-in-DB path. The driver-registry factory is synchronous (Func<string,string,IDriver>), so resolution is done lazily in the async session-open (InitializeAsync, before BuildUserIdentity) rather than at deserialize — mirroring Task 7's Galaxy pattern and matching its re-resolve-on-reconnect behavior. Both consumers (username Password and the certificate-password LoadPkcs12 path via BuildCertificateIdentity) see the resolved connect-scoped options; _options stays raw (secret: refs intact), no long-lived plaintext field. Scope corrections vs the plan (verified against v3): the probe is unauthenticated GetEndpoints-only and never reads either credential, so it is NOT a resolution site (comment added, no dead code); OpcUaClientDriverOptions was a sealed class, converted to sealed record for the with-expression (no positional params → identical JSON; no reference-equality/dict-key/ToString-log usages → no behavior/leak change). ISecretResolver threaded via factory Register/CreateInstance + DriverFactoryBootstrap (real resolver from DI); NullSecretResolver null-object backs test/parse paths only, fail-closed on secret: refs. TDD: 4 helper tests RED→GREEN; 142 OpcUaClient tests pass.
This commit is contained in:
+3
-1
@@ -14,7 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|||||||
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
|
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
|
||||||
/// protections cover it.
|
/// protections cover it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
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
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
|
||||||
|
/// (absent). Backs the driver's default (test/parse-only) construction path so callers that
|
||||||
|
/// never author a <c>secret:</c> credential ref need not thread a real resolver. A production
|
||||||
|
/// <c>OpcUaClientDriver</c> always receives the DI-registered resolver via the factory; a
|
||||||
|
/// <c>secret:</c> 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
|
||||||
|
/// <c>secret:</c> literal is never sent verbatim as a password.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NullSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <summary>The shared singleton instance.</summary>
|
||||||
|
public static readonly NullSecretResolver Instance = new();
|
||||||
|
|
||||||
|
private NullSecretResolver()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Opc.Ua;
|
|||||||
using Opc.Ua.Client;
|
using Opc.Ua.Client;
|
||||||
using Opc.Ua.Configuration;
|
using Opc.Ua.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
@@ -36,15 +37,25 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// <param name="options">Driver configuration.</param>
|
/// <param name="options">Driver configuration.</param>
|
||||||
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
|
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
|
||||||
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
|
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// Optional shared secret resolver used to resolve <c>secret:</c>-prefixed
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> at session-open.
|
||||||
|
/// Defaults to the <see cref="NullSecretResolver"/> null-object (which reports every
|
||||||
|
/// secret absent) for the test/parse-only path; the production factory threads the
|
||||||
|
/// DI-registered resolver. A <c>secret:</c> ref against the null-object fails closed.
|
||||||
|
/// </param>
|
||||||
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
|
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
|
||||||
ILogger<OpcUaClientDriver>? logger = null)
|
ILogger<OpcUaClientDriver>? logger = null, ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
_driverInstanceId = driverInstanceId;
|
_driverInstanceId = driverInstanceId;
|
||||||
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
||||||
|
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly OpcUaClientDriverOptions _options;
|
private readonly OpcUaClientDriverOptions _options;
|
||||||
|
private readonly ISecretResolver _secretResolver;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
// ---- IAlarmSource state ----
|
// ---- IAlarmSource state ----
|
||||||
|
|
||||||
@@ -159,7 +170,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var candidates = ResolveEndpointCandidates(_options);
|
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
|
// 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
|
// one that successfully connects. Per-endpoint failures are captured so the final
|
||||||
|
|||||||
+23
-5
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
@@ -31,22 +32,37 @@ public static class OpcUaClientDriverFactoryExtensions
|
|||||||
Converters = { new JsonStringEnumConverter() },
|
Converters = { new JsonStringEnumConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Register the OpcUaClient factory with the driver registry.</summary>
|
/// <summary>Register the OpcUaClient factory with the driver registry, threading the shared secret resolver.</summary>
|
||||||
/// <param name="registry">The driver factory registry to register with.</param>
|
/// <param name="registry">The driver factory registry to register with.</param>
|
||||||
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
|
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
|
||||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
/// <param name="secretResolver">
|
||||||
|
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of each
|
||||||
|
/// instance's <c>Password</c> / <c>UserCertificatePassword</c> at session-open. Defaults to
|
||||||
|
/// the <see cref="NullSecretResolver"/> null-object for the test/standalone path — callers
|
||||||
|
/// that need a working <c>secret:</c> credential ref must thread the DI resolver.
|
||||||
|
/// </param>
|
||||||
|
public static void Register(
|
||||||
|
DriverFactoryRegistry registry,
|
||||||
|
ILoggerFactory? loggerFactory = null,
|
||||||
|
ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registry);
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
||||||
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
|
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// Optional shared secret resolver for the driver's <c>secret:</c> credential arm; defaults
|
||||||
|
/// to the <see cref="NullSecretResolver"/> null-object (fail-closed on a <c>secret:</c> ref).
|
||||||
|
/// </param>
|
||||||
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
|
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
|
||||||
public static OpcUaClientDriver CreateInstance(
|
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(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
@@ -55,6 +71,8 @@ public static class OpcUaClientDriverFactoryExtensions
|
|||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
|
||||||
return new OpcUaClientDriver(options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>());
|
return new OpcUaClientDriver(
|
||||||
|
options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>(),
|
||||||
|
secretResolver ?? NullSecretResolver.Instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
public async Task<DriverProbeResult> 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;
|
OpcUaClientDriverOptions? opts;
|
||||||
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
||||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Layer-B (G-2b) secret resolution for the OPC UA Client driver's credential fields. Resolves
|
||||||
|
/// <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/> and
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
||||||
|
/// <see cref="ISecretResolver"/> (the encrypted-at-rest store) just before the async
|
||||||
|
/// session-open path consumes them — retiring the cleartext password-in-DB model for
|
||||||
|
/// production.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Resolution is lazy (called from the connect flow, not at config deserialization) so a
|
||||||
|
/// full reconnect via <c>InitializeAsync</c> re-resolves and picks up secret rotations,
|
||||||
|
/// mirroring the Galaxy driver's re-resolve-on-reconnect behaviour. Only <c>secret:</c>-prefixed
|
||||||
|
/// values are resolved; a null/empty or non-<c>secret:</c> value passes through verbatim so the
|
||||||
|
/// literal-password back-compat path is preserved. The <c>secret:</c> arm is <b>fail-closed</b>:
|
||||||
|
/// an absent/tombstoned secret throws rather than leaving the <c>secret:</c> literal in place,
|
||||||
|
/// which would otherwise be sent verbatim to the remote server as the password.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class OpcUaClientSecretResolution
|
||||||
|
{
|
||||||
|
private const string SecretPrefix = "secret:";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> resolved to their
|
||||||
|
/// plaintext via <paramref name="resolver"/>. Non-secret / null / empty fields are
|
||||||
|
/// returned unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The raw driver options (credential fields may carry <c>secret:</c> refs).</param>
|
||||||
|
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the async secret resolution.</param>
|
||||||
|
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
|
||||||
|
/// </exception>
|
||||||
|
internal static async Task<OpcUaClientDriverOptions> 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
|
||||||
|
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
|
||||||
|
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
|
||||||
|
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
|
||||||
|
/// <param name="resolver">The shared secret resolver.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the async resolution.</param>
|
||||||
|
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
|
||||||
|
private static async Task<string?> 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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,7 +144,7 @@ public static class DriverFactoryBootstrap
|
|||||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, 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.S7.S7DriverFactoryExtensions.Register(registry);
|
||||||
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
||||||
}
|
}
|
||||||
|
|||||||
+95
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// G-2b — pins <see cref="OpcUaClientSecretResolution.ResolveSecretRefsAsync"/>, the Layer-B
|
||||||
|
/// arm that resolves <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/>
|
||||||
|
/// and <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
||||||
|
/// <see cref="ISecretResolver"/> just before the async session-open consumes them. The
|
||||||
|
/// <c>secret:</c> arm is fail-closed — an absent secret throws rather than leaving the
|
||||||
|
/// <c>secret:</c> literal in place (which would be sent verbatim as the password).
|
||||||
|
/// Non-secret literals and null/empty pass through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||||
|
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
||||||
|
|
||||||
|
/// <summary>(a) A <c>secret:</c> Password resolves to the store's plaintext.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(b) A <c>secret:</c> UserCertificatePassword resolves to the store's plaintext.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(c) A non-secret literal Password and a null field pass through unchanged.</summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(d) An unknown <c>secret:</c> ref (resolver returns null) is fail-closed — it throws
|
||||||
|
/// and never leaves the <c>secret:</c> literal in place.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_secret_ref_is_fail_closed()
|
||||||
|
{
|
||||||
|
var options = new OpcUaClientDriverOptions { Password = "secret:opcua/inst/absent" };
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
|
OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||||
|
options, FakeResolver(), CancellationToken.None));
|
||||||
|
|
||||||
|
ex.Message.ShouldContain("Password");
|
||||||
|
ex.Message.ShouldContain("opcua/inst/absent");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||||
|
private sealed class StubSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(name.Value switch
|
||||||
|
{
|
||||||
|
KnownPwName => KnownPwValue,
|
||||||
|
KnownCertPwName => KnownCertPwValue,
|
||||||
|
_ => null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user