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