using Opc.Ua; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// /// The ONE place the OpcUaClient user identity is built from — shared by /// the runtime driver's session open and the AdminUI live browse, so a certificate-authenticated upstream is /// browsable with exactly the credentials the deployed driver will present. Includes the secret: /// resolution of the two credential fields (fail-closed). /// public static class OpcUaUserIdentityFactory { private const string SecretPrefix = "secret:"; /// /// Build the user-identity token from options whose credential fields are already resolved plaintext /// (see ). /// /// Driver options containing authentication configuration. /// The constructed for session activation. public static UserIdentity Build(OpcUaClientDriverOptions options) => options.AuthType switch { OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()), OpcUaAuthType.Username => new UserIdentity( options.Username ?? string.Empty, System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)), OpcUaAuthType.Certificate => FromCertificate(options), _ => new UserIdentity(new AnonymousIdentityToken()), }; /// /// Build a carrying a client user-authentication certificate loaded from /// . Fails fast if the path is missing, the file /// doesn't exist, or the certificate lacks a private key (required to sign the user-token challenge). /// /// Driver options containing certificate configuration. /// The certificate-backed . public static UserIdentity FromCertificate(OpcUaClientDriverOptions options) => new(LoadCertificate(options)); /// /// Loads (and validates) the user-authentication certificate wraps. The caller owns /// the returned certificate — dispose it when the session that presented it is gone. /// /// Driver options containing certificate configuration. /// The certificate, with its private key. public static System.Security.Cryptography.X509Certificates.X509Certificate2 LoadCertificate(OpcUaClientDriverOptions options) { if (string.IsNullOrWhiteSpace(options.UserCertificatePath)) throw new InvalidOperationException( "OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set."); if (!File.Exists(options.UserCertificatePath)) throw new FileNotFoundException( $"User certificate not found at '{options.UserCertificatePath}'.", options.UserCertificatePath); // X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX // since the legacy X509Certificate2 ctors are marked obsolete on net10. var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader .LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword); if (!cert.HasPrivateKey) { cert.Dispose(); throw new InvalidOperationException( $"User certificate at '{options.UserCertificatePath}' has no private key — " + "the private key is required to sign the OPC UA user-token challenge at session activation."); } return cert; } /// /// Return a copy of with any secret:-prefixed /// / /// resolved through . Non-secret / null / empty fields pass through unchanged (the /// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a /// secret: ref with no resolver available, throws rather than sending the literal as a credential. /// /// The raw options (credential fields may carry secret: refs). /// The shared secret resolver, or null when none is available. /// Cancellation token. /// An options copy whose credential fields carry resolved plaintext. public static async Task ResolveSecretRefsAsync( OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct) { ArgumentNullException.ThrowIfNull(options); 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); if (ReferenceEquals(password, options.Password) && ReferenceEquals(certPassword, options.UserCertificatePassword)) { return options; } return options with { Password = password, UserCertificatePassword = certPassword }; } 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..]; if (resolver is null) throw new InvalidOperationException( $"OpcUaClientDriverOptions.{fieldName}='{value}' references secret '{name}', but no secret store is " + "available here (fail-closed)."); 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)."); } }