9bb237b794
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.
91 lines
4.9 KiB
C#
91 lines
4.9 KiB
C#
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).");
|
|
}
|
|
}
|