f0d807e7b7
- m1: the form hint now says ProbeTimeoutSeconds can only shorten Test Connect's 10 s budget (passing the configured value as the button's budget is shared code - follow-up). - m2: a superseded init no longer overwrites _options (generation-checked under _probeLock) or the cert-validation handler (handed back and published with the session; detached when not installed; a previous one detached on replace). - m3: an ack none of whose condition ids resolves throws BadNodeIdUnknown + Warning. - m4: an ack with no reply (transport failure / non-caller timeout) degrades. - m5: no session before the gate is the same ServiceResultException(BadNotConnected). - m6: the caller-cancelled-write deviation is documented. - m7: a reply during a published outage advances the stamp but does not write Healthy; the keep-alive recovery ends the outage. - m8: test for a non-caller OperationCanceledException write -> Degraded. - m9: the browse session owns and disposes the user certificate it loaded (OpcUaUserIdentityFactory.LoadCertificate; a key-less cert is disposed before the throw); AdminUI-host certificate path documented. - m10: a subscribe overtaken by a shutdown / re-init removes its half-built subscription instead of registering it (data and alarm). - Sync Dispose drains each gate for 500 ms (was 2 s). Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
127 lines
6.6 KiB
C#
127 lines
6.6 KiB
C#
using Opc.Ua;
|
|
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) => new(LoadCertificate(options));
|
|
|
|
/// <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).");
|
|
}
|
|
}
|