using Opc.Ua; using Opc.Ua.Security.Certificates; 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) { using var certificate = LoadCertificate(options); var identifier = new CertificateIdentifier { Thumbprint = certificate.Thumbprint }; return new UserIdentity(new X509IdentityTokenHandler(identifier, new CertificatePasswordProvider(), new FileCertificateProvider(options, certificate.Thumbprint))); } // The configured credential is a PFX file, rather than an SDK certificate store. SDK 2 resolves // signing keys on demand and disposes each returned Certificate. Pin the public identity for this // lifecycle; a file/secret rotation is picked up only by the next initialise/open. private sealed class FileCertificateProvider(OpcUaClientDriverOptions options, string thumbprint) : ICertificateProvider { public Certificate? TryGetPrivateKeyCertificate(string requestedThumbprint) => null; public ValueTask GetPrivateKeyCertificateAsync(CertificateIdentifier identifier, ICertificatePasswordProvider? passwordProvider = null, string? applicationUri = null, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var certificate = LoadCertificate(options); if (!string.Equals(certificate.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase) || !string.Equals(identifier.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)) { certificate.Dispose(); throw new ServiceResultException(StatusCodes.BadIdentityTokenInvalid, "The user certificate changed; reopen the session to use the rotated credential."); } return ValueTask.FromResult(Certificate.From(certificate)); } } /// /// 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)."); } }