Compare commits
2 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a79c5f3008 | ||
| a5299a2fee |
@@ -90,8 +90,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|||||||
OpcUaAuthType.Username => new UserIdentity(
|
OpcUaAuthType.Username => new UserIdentity(
|
||||||
_options.Username ?? string.Empty,
|
_options.Username ?? string.Empty,
|
||||||
System.Text.Encoding.UTF8.GetBytes(_options.Password ?? string.Empty)),
|
System.Text.Encoding.UTF8.GetBytes(_options.Password ?? string.Empty)),
|
||||||
OpcUaAuthType.Certificate => throw new NotSupportedException(
|
OpcUaAuthType.Certificate => BuildCertificateIdentity(_options),
|
||||||
"Certificate authentication lands in a follow-up PR; for now use Anonymous or Username"),
|
|
||||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
_ => new UserIdentity(new AnonymousIdentityToken()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,6 +270,39 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication
|
||||||
|
/// certificate loaded from <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>.
|
||||||
|
/// Used when the remote server's endpoint advertises Certificate-type user tokens.
|
||||||
|
/// Fails fast if the path is missing, the file doesn't exist, or the certificate
|
||||||
|
/// lacks a private key (the private key is required to sign the user-token
|
||||||
|
/// challenge during session activation).
|
||||||
|
/// </summary>
|
||||||
|
internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
|
||||||
|
if (!System.IO.File.Exists(options.UserCertificatePath))
|
||||||
|
throw new System.IO.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. Passes the
|
||||||
|
// password through verbatim; PEM files with external keys fall back to
|
||||||
|
// LoadCertificateFromFile which picks up the adjacent .key if present.
|
||||||
|
var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
|
||||||
|
.LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
|
||||||
|
|
||||||
|
if (!cert.HasPrivateKey)
|
||||||
|
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 new UserIdentity(cert);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
|
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
|
||||||
internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch
|
internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ public sealed class OpcUaClientDriverOptions
|
|||||||
/// <summary>Password (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
|
/// <summary>Password (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
|
||||||
public string? Password { get; init; }
|
public string? Password { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filesystem path to the user-identity certificate (PFX/PEM). Required when
|
||||||
|
/// <see cref="AuthType"/> is <see cref="OpcUaAuthType.Certificate"/>. The driver
|
||||||
|
/// loads the cert + private key, which the remote server validates against its
|
||||||
|
/// <c>TrustedUserCertificates</c> store to authenticate the session's user token.
|
||||||
|
/// Leave unset to use the driver's application-instance certificate as the user
|
||||||
|
/// token (not typical — most deployments have a separate user cert).
|
||||||
|
/// </summary>
|
||||||
|
public string? UserCertificatePath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional password that unlocks <see cref="UserCertificatePath"/> when the PFX is
|
||||||
|
/// protected. PEM files generally have their password on the adjacent key file; this
|
||||||
|
/// knob only applies to password-locked PFX.
|
||||||
|
/// </summary>
|
||||||
|
public string? UserCertificatePassword { get; init; }
|
||||||
|
|
||||||
/// <summary>Server-negotiated session timeout. Default 120s per driver-specs.md §8.</summary>
|
/// <summary>Server-negotiated session timeout. Default 120s per driver-specs.md §8.</summary>
|
||||||
public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120);
|
public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class OpcUaClientCertAuthTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildCertificateIdentity_rejects_missing_path()
|
||||||
|
{
|
||||||
|
var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate };
|
||||||
|
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts))
|
||||||
|
.Message.ShouldContain("UserCertificatePath");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCertificateIdentity_rejects_nonexistent_file()
|
||||||
|
{
|
||||||
|
var opts = new OpcUaClientDriverOptions
|
||||||
|
{
|
||||||
|
AuthType = OpcUaAuthType.Certificate,
|
||||||
|
UserCertificatePath = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.pfx"),
|
||||||
|
};
|
||||||
|
Should.Throw<FileNotFoundException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCertificateIdentity_loads_a_valid_PFX_with_private_key()
|
||||||
|
{
|
||||||
|
// Generate a self-signed cert on the fly so the test doesn't ship a static PFX.
|
||||||
|
// The driver doesn't care about the issuer — just needs a cert with a private key.
|
||||||
|
using var rsa = RSA.Create(2048);
|
||||||
|
var req = new CertificateRequest("CN=OpcUaClientCertAuthTests", rsa,
|
||||||
|
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||||
|
|
||||||
|
var tmpPath = Path.Combine(Path.GetTempPath(), $"opcua-cert-test-{Guid.NewGuid():N}.pfx");
|
||||||
|
File.WriteAllBytes(tmpPath, cert.Export(X509ContentType.Pfx, "testpw"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var opts = new OpcUaClientDriverOptions
|
||||||
|
{
|
||||||
|
AuthType = OpcUaAuthType.Certificate,
|
||||||
|
UserCertificatePath = tmpPath,
|
||||||
|
UserCertificatePassword = "testpw",
|
||||||
|
};
|
||||||
|
var identity = OpcUaClientDriver.BuildCertificateIdentity(opts);
|
||||||
|
identity.ShouldNotBeNull();
|
||||||
|
identity.TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { File.Delete(tmpPath); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user