Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs
T
2026-11-10 10:08:12 -05:00

158 lines
8.3 KiB
C#

using Opc.Ua;
using Opc.Ua.Security.Certificates;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// The ONE place the OpcUaClient user identity is built from <see cref="OpcUaClientDriverOptions"/> — 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 <c>secret:</c>
/// resolution of the two credential fields (fail-closed).
/// </summary>
public static class OpcUaUserIdentityFactory
{
private const string SecretPrefix = "secret:";
/// <summary>
/// Build the user-identity token from options whose credential fields are already resolved plaintext
/// (see <see cref="ResolveSecretRefsAsync"/>).
/// </summary>
/// <param name="options">Driver options containing authentication configuration.</param>
/// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns>
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()),
};
/// <summary>
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication certificate loaded from
/// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>. 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).
/// </summary>
/// <param name="options">Driver options containing certificate configuration.</param>
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
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<Certificate?> 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?>(Certificate.From(certificate));
}
}
/// <summary>
/// Loads (and validates) the user-authentication certificate <see cref="FromCertificate"/> wraps. The caller owns
/// the returned certificate — dispose it when the session that presented it is gone.
/// </summary>
/// <param name="options">Driver options containing certificate configuration.</param>
/// <returns>The certificate, with its private key.</returns>
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;
}
/// <summary>
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
/// <see cref="OpcUaClientDriverOptions.Password"/> / <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/>
/// resolved through <paramref name="resolver"/>. Non-secret / null / empty fields pass through unchanged (the
/// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a
/// <c>secret:</c> ref with no resolver available, throws rather than sending the literal as a credential.
/// </summary>
/// <param name="options">The raw options (credential fields may carry <c>secret:</c> refs).</param>
/// <param name="resolver">The shared secret resolver, or null when none is available.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
public static async Task<OpcUaClientDriverOptions> 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<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..];
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).");
}
}