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).");
}
}