test: prove trusted channels PKI and scoped audit compatibility
This commit is contained in:
+14
-1
@@ -137,6 +137,7 @@ public sealed class AclServerObjectModelChangeTests
|
||||
rig.EnsureEquipment();
|
||||
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
rig.Sink.EnsureVariable(RawVariable, RawDeviceFolder, "Speed", "Int32", writable: true, AddressSpaceRealm.Raw);
|
||||
rig.Sink.WriteValue(RawVariable, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
rig.Nm.NodeWriteGateway = new RejectingGateway();
|
||||
|
||||
var (granted, grantedEvents, grantedItem, grantedSubscription) =
|
||||
@@ -145,6 +146,15 @@ public sealed class AclServerObjectModelChangeTests
|
||||
await ServerObjectAsync(rig, anonymous: true, ct, encrypted: true);
|
||||
|
||||
var variable = new NodeId(RawVariable, (ushort)granted.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||||
var serverSession = rig.Server.CurrentInstance.SessionManager.GetSessions().Single(s =>
|
||||
s.Identity is ZB.MOM.WW.OtOpcUa.OpcUaServer.Security.RoleCarryingUserIdentity { ApiKeyId: KeyId });
|
||||
using var permissionContext = new Opc.Ua.Server.OperationContext(new RequestHeader(), null!,
|
||||
Opc.Ua.Server.RequestType.Read, RequestLifetime.None, serverSession);
|
||||
var typePermission = rig.Nm.ValidateRolePermissions(permissionContext, ObjectTypeIds.AuditWriteUpdateEventType, PermissionType.ReceiveEvents);
|
||||
var sourcePermission = rig.Nm.ValidateRolePermissions(permissionContext, variable, PermissionType.ReceiveEvents);
|
||||
typePermission.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied,
|
||||
"ordinary SDK audit emission must stay denied; only the scoped application path may grant receive permission");
|
||||
ServiceResult.IsGood(sourcePermission).ShouldBeTrue($"Audit source permission: {sourcePermission}");
|
||||
var write = await granted.WriteAsync(
|
||||
null,
|
||||
[new WriteValue { NodeId = variable, AttributeId = Attributes.Value, Value = new DataValue(new Variant(42)) }],
|
||||
@@ -156,8 +166,11 @@ public sealed class AclServerObjectModelChangeTests
|
||||
|
||||
// Reported after the audit event through the same delivery, so it queues behind it on the item.
|
||||
rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns);
|
||||
await RecordedEvents.WaitUntilAsync(() => narrowEvents.Of(narrowItem).Any(NamesEqA));
|
||||
await RecordedEvents.WaitUntilAsync(() => narrowEvents.Of(narrowItem).Any(NamesEqA)
|
||||
&& grantedEvents.Of(grantedItem).Any(NamesEqA));
|
||||
|
||||
grantedEvents.Of(grantedItem).Count(f => f[RecordedEvents.EventTypeField].GetNodeId() == ObjectTypeIds.AuditWriteUpdateEventType)
|
||||
.ShouldBe(1, "one application audit is delivered without an automatic SDK duplicate");
|
||||
narrowEvents.HasType(narrowItem, ObjectTypeIds.AuditWriteUpdateEventType).ShouldBeFalse(
|
||||
"a session that may not read the node must not receive its written values through an audit event");
|
||||
|
||||
|
||||
+2
-1
@@ -144,7 +144,7 @@ public sealed class AdvertisedAggregateFunctionsWireTests
|
||||
rig.Enforce(false, [Key()]);
|
||||
var id = rig.EnsureRawVariable("agg", "Double", writable: false);
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var aggregateId = (NodeId)typeof(ObjectIds).GetField("AggregateFunction_" + aggregateName)!.GetValue(null)!;
|
||||
var aggregateId = (NodeId)typeof(ObjectIds).GetProperty("AggregateFunction_" + aggregateName)!.GetValue(null)!;
|
||||
|
||||
(await AdvertisedAsync(session, ObjectIds.HistoryServerCapabilities_AggregateFunctions, ct))
|
||||
.ShouldNotContain(aggregateName);
|
||||
@@ -164,6 +164,7 @@ public sealed class AdvertisedAggregateFunctionsWireTests
|
||||
{
|
||||
AggregateType = aggregateId,
|
||||
ProcessingInterval = 1000,
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
StartTime = DateTime.UtcNow,
|
||||
},
|
||||
};
|
||||
|
||||
+6151
-4146
File diff suppressed because one or more lines are too long
+28
-10
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
public sealed class Sdk2AggregateCompatibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void All_37_factories_preserve_the_4144_baseline_vectors()
|
||||
public void All_37_factories_preserve_the_6144_baseline_vectors()
|
||||
{
|
||||
var baseline = JsonSerializer.Deserialize<Baseline>(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "opcua-1.5.378.182-aggregates.json")), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
@@ -22,22 +22,29 @@ public sealed class Sdk2AggregateCompatibilityTests
|
||||
{
|
||||
var id = (NodeId)typeof(ObjectIds).GetProperty(vector.Aggregate)!.GetValue(null)!;
|
||||
var start = epoch.AddSeconds(vector.Reverse ? 70 : 0); var end = epoch.AddSeconds(vector.Reverse ? 0 : 70);
|
||||
var config = new AggregateConfiguration { TreatUncertainAsBad = vector.UncertainAsBad, PercentDataBad = 100, PercentDataGood = 100, UseSlopedExtrapolation = false, UseServerCapabilitiesDefaults = false };
|
||||
var profile = vector.Configuration;
|
||||
var config = new AggregateConfiguration
|
||||
{
|
||||
TreatUncertainAsBad = profile?.TreatUncertainAsBad ?? vector.UncertainAsBad,
|
||||
PercentDataBad = profile?.PercentDataBad ?? 100, PercentDataGood = profile?.PercentDataGood ?? 100,
|
||||
UseSlopedExtrapolation = profile?.UseSlopedExtrapolation ?? false,
|
||||
UseServerCapabilitiesDefaults = profile?.UseServerCapabilitiesDefaults ?? false,
|
||||
};
|
||||
var calculator = CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry);
|
||||
var output = new List<Value>(); string? error = null;
|
||||
var output = new List<Value>(); var accepted = new List<bool>(); string? error = null;
|
||||
try
|
||||
{
|
||||
var samples = baseline.Data[vector.DataSet].OrderBy(x => x.Seconds);
|
||||
foreach (var sample in vector.Reverse ? samples.Reverse() : samples)
|
||||
{
|
||||
var time = epoch.AddSeconds(sample.Seconds);
|
||||
calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time));
|
||||
accepted.Add(calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time.AddTicks(sample.ServerOffsetTicks), sample.SourcePicoseconds, sample.ServerPicoseconds)));
|
||||
if (vector.Incremental) Drain(false);
|
||||
}
|
||||
Drain(true);
|
||||
}
|
||||
catch (Exception ex) { error = ex.GetType().FullName; }
|
||||
if (error != vector.Exception || !output.SequenceEqual(vector.Output))
|
||||
if (error != vector.Exception || !output.SequenceEqual(vector.Output) || (vector.QueueAccepted is { } expectedAccepted && !accepted.SequenceEqual(expectedAccepted)))
|
||||
{
|
||||
failures.Add($"{vector.Aggregate}/{vector.DataSet}/rev={vector.Reverse}/step={vector.Stepped}/uncertainBad={vector.UncertainAsBad}/incremental={vector.Incremental}: error {vector.Exception} vs {error}; expected {JsonSerializer.Serialize(vector.Output)} actual {JsonSerializer.Serialize(output)}");
|
||||
}
|
||||
@@ -47,20 +54,31 @@ public sealed class Sdk2AggregateCompatibilityTests
|
||||
{
|
||||
if (!calculator.TryGetProcessedValue(partial, out var value)) return;
|
||||
var payload = SdkValueBoundary.ToClr(value.WrappedValue);
|
||||
output.Add(new(partial, payload?.GetType().Name, Format(payload), value.StatusCode.Code, ((DateTime)value.SourceTimestamp).Ticks, ((DateTime)value.ServerTimestamp).Ticks, value.SourcePicoseconds, value.ServerPicoseconds));
|
||||
output.Add(new(partial, payload?.GetType().Name, Format(payload), value.StatusCode.Code, LegacyTimestampTicks(value.SourceTimestamp), LegacyTimestampTicks(value.ServerTimestamp), value.SourcePicoseconds, value.ServerPicoseconds));
|
||||
}
|
||||
throw new InvalidOperationException("Baseline calculator did not terminate.");
|
||||
}
|
||||
}
|
||||
baseline.Vectors.Length.ShouldBe(4144);
|
||||
baseline.Vectors.Length.ShouldBe(6144);
|
||||
baseline.Vectors.Select(x => x.Aggregate).Distinct().Count().ShouldBe(37);
|
||||
failures.ShouldBeEmpty("The application must retain baseline values, CLR types, complete status bits, timestamps and incremental processing.");
|
||||
}
|
||||
|
||||
static object? Payload(Sample sample) => sample.Type switch { "Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Boolean" => bool.Parse(sample.Payload!), _ => null };
|
||||
// UA FILETIME zero is the old CLR unset sentinel, not a different historical instant.
|
||||
private static long LegacyTimestampTicks(DateTimeUtc time) => time.IsNull ? DateTime.MinValue.Ticks : ((DateTime)time).Ticks;
|
||||
static object? Payload(Sample sample) => sample.Type switch { "SByte" => sbyte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Byte" => byte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Int16" => short.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"UInt16" => ushort.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Int32" => int.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"UInt32" => uint.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"UInt64" => ulong.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Single" => float.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Boolean" => bool.Parse(sample.Payload!), _ => null };
|
||||
static string? Format(object? value) => value switch { null => null, double number => number.ToString("R", CultureInfo.InvariantCulture), float number => number.ToString("R", CultureInfo.InvariantCulture), IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() };
|
||||
record Baseline(long EpochTicks, int IntervalMilliseconds, Dictionary<string,Sample[]> Data, Vector[] Vectors);
|
||||
record Sample(double Seconds,string Type,string? Payload,uint Status);
|
||||
record Sample(double Seconds,string Type,string? Payload,uint Status,ushort SourcePicoseconds = 0,ushort ServerPicoseconds = 0,long ServerOffsetTicks = 0);
|
||||
record Value(bool PartialRequested,string? Type,string? Payload,uint Status,long SourceTicks,long ServerTicks,ushort SourcePicoseconds,ushort ServerPicoseconds);
|
||||
record Vector(string Aggregate,string DataSet,bool Reverse,bool Stepped,bool UncertainAsBad,bool Incremental,Value[] Output,string? Exception);
|
||||
record Vector(string Aggregate,string DataSet,bool Reverse,bool Stepped,bool UncertainAsBad,bool Incremental,Value[] Output,string? Exception, Configuration? Configuration = null,bool[]? QueueAccepted = null);
|
||||
record Configuration(bool TreatUncertainAsBad,byte PercentDataBad,byte PercentDataGood,bool UseSlopedExtrapolation,bool UseServerCapabilitiesDefaults);
|
||||
}
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>Exercises the target certificate manager with real issuer stores and signed certificates.</summary>
|
||||
public sealed class Sdk2CertificateValidationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("valid")]
|
||||
[InlineData("expired")]
|
||||
[InlineData("revoked")]
|
||||
[InlineData("missing-issuer")]
|
||||
[InlineData("bad-issuer-usage")]
|
||||
public async Task Trust_and_certificate_failures_keep_the_configured_rejection_policy(string scenario)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var pki = await ValidationPki.CreateAsync(scenario, ct);
|
||||
var result = await pki.Configuration.CertificateManager.ValidateAsync(pki.Leaf, ct: ct);
|
||||
if (scenario == "valid") result.IsValid.ShouldBeTrue(result.ToString());
|
||||
else
|
||||
{
|
||||
result.IsValid.ShouldBeFalse();
|
||||
var expected = scenario switch
|
||||
{
|
||||
"expired" => StatusCodes.BadCertificateTimeInvalid,
|
||||
"revoked" => StatusCodes.BadCertificateRevoked,
|
||||
"missing-issuer" => StatusCodes.BadCertificateChainIncomplete,
|
||||
_ => StatusCodes.BadCertificateIssuerUseNotAllowed,
|
||||
};
|
||||
(result.StatusCode == expected || result.Errors.Any(e => e.StatusCode == expected)).ShouldBeTrue(
|
||||
$"Expected {expected}; actual {result.StatusCode}: {string.Join(", ", result.Errors)}");
|
||||
}
|
||||
pki.Configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task A_trusted_chain_does_not_bypass_endpoint_identity_checks(bool wrongUri)
|
||||
{
|
||||
await using var pki = await ValidationPki.CreateAsync("valid", TestContext.Current.CancellationToken);
|
||||
(await pki.Configuration.CertificateManager.ValidateAsync(pki.Leaf, ct: TestContext.Current.CancellationToken))
|
||||
.IsValid.ShouldBeTrue();
|
||||
var endpoint = new ConfiguredEndpoint(null, new EndpointDescription
|
||||
{
|
||||
EndpointUrl = wrongUri ? "opc.tcp://localhost:4840" : "opc.tcp://other.invalid:4840",
|
||||
Server = new ApplicationDescription { ApplicationUri = wrongUri ? "urn:wrong-application" : ValidationPki.PeerUri },
|
||||
}, EndpointConfiguration.Create(pki.Configuration));
|
||||
var error = Should.Throw<ServiceResultException>(() =>
|
||||
{
|
||||
if (wrongUri) pki.Configuration.CertificateManager.ValidateApplicationUri(pki.Leaf, endpoint);
|
||||
else pki.Configuration.CertificateManager.ValidateDomains(pki.Leaf, endpoint);
|
||||
});
|
||||
error.StatusCode.ShouldBe(wrongUri ? StatusCodes.BadCertificateUriInvalid : StatusCodes.BadCertificateHostNameInvalid);
|
||||
}
|
||||
|
||||
private sealed class ValidationPki : IAsyncDisposable
|
||||
{
|
||||
internal const string PeerUri = "urn:Sdk2ValidationPeer";
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otua-validation-{Guid.NewGuid():N}");
|
||||
private ApplicationInstance? _application;
|
||||
private Certificate? _issuer;
|
||||
internal Certificate Leaf { get; private set; } = null!;
|
||||
internal ApplicationConfiguration Configuration { get; private set; } = null!;
|
||||
|
||||
internal static async Task<ValidationPki> CreateAsync(string scenario, CancellationToken ct)
|
||||
{
|
||||
var pki = new ValidationPki();
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var issuerBuilder = CertificateBuilder.Create("CN=Sdk2ValidationIssuer")
|
||||
.SetNotBefore(now.AddDays(-10)).SetNotAfter(now.AddDays(365)).SetCAConstraint(-1);
|
||||
if (scenario == "bad-issuer-usage")
|
||||
issuerBuilder.AddExtension(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
|
||||
pki._issuer = issuerBuilder.SetRSAKeySize(2048).CreateForRSA();
|
||||
var names = new SubjectAlternativeNameBuilder();
|
||||
names.AddUri(new Uri(PeerUri)); names.AddDnsName("localhost");
|
||||
pki.Leaf = CertificateBuilder.Create("CN=Sdk2ValidationPeer")
|
||||
.SetNotBefore(now.AddDays(-2)).SetNotAfter(scenario == "expired" ? now.AddDays(-1) : now.AddDays(30))
|
||||
.AddExtension(names.Build()).SetIssuer(pki._issuer).SetRSAKeySize(2048).CreateForRSA();
|
||||
if (scenario != "missing-issuer")
|
||||
{
|
||||
var certs = Path.Combine(pki._root, "trusted", "certs"); Directory.CreateDirectory(certs);
|
||||
await File.WriteAllBytesAsync(Path.Combine(certs, "issuer.der"), pki._issuer.RawData, ct);
|
||||
if (scenario == "revoked")
|
||||
{
|
||||
var crls = Path.Combine(pki._root, "trusted", "crl"); Directory.CreateDirectory(crls);
|
||||
var crl = new X509CRL(CrlBuilder.Create(pki._issuer.SubjectName).AddRevokedCertificate(pki.Leaf).CreateForRSA(pki._issuer));
|
||||
await File.WriteAllBytesAsync(Path.Combine(crls, "issuer.crl"), crl.RawData, ct);
|
||||
}
|
||||
}
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var security = TestClientSecurity.Build(pki._root); security.AutoAcceptUntrustedCertificates = false;
|
||||
pki.Configuration = new ApplicationConfiguration(telemetry)
|
||||
{
|
||||
ApplicationName = "Sdk2Validation", ApplicationUri = "urn:Sdk2Validation", ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = security, ClientConfiguration = new ClientConfiguration(),
|
||||
};
|
||||
pki._application = new ApplicationInstance(telemetry) { ApplicationConfiguration = pki.Configuration };
|
||||
await pki.Configuration.ValidateAsync(ApplicationType.Client, ct);
|
||||
return pki;
|
||||
}
|
||||
catch { await pki.DisposeAsync(); throw; }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_application is not null) await _application.DisposeAsync();
|
||||
Leaf?.Dispose(); _issuer?.Dispose();
|
||||
if (Directory.Exists(_root)) Directory.Delete(_root, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using Opc.Ua.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>Real, mutually trusted channels: no auto-accept callbacks or insecure fallback.</summary>
|
||||
public sealed class Sdk2TrustedChannelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(OpcUaSecurityProfile.Basic256Sha256Sign, MessageSecurityMode.Sign)]
|
||||
[InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, MessageSecurityMode.SignAndEncrypt)]
|
||||
public async Task Trusted_key_retains_narrow_authority_and_revocation_reaches_its_open_session(
|
||||
OpcUaSecurityProfile profile, MessageSecurityMode mode)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await TrustedRig.StartAsync(profile, ct);
|
||||
using var session = await rig.ConnectAsync("contract-key", ct);
|
||||
session.Endpoint.SecurityMode.ShouldBe(mode);
|
||||
(await session.ReadValueAsync(rig.Node(session), ct)).WrappedValue.GetInt32().ShouldBe(7);
|
||||
(await rig.WriteAsync(session, 42, ct)).ShouldBe(StatusCodes.Good);
|
||||
rig.Writes.ShouldBe(1, "a narrow grant with no application-wide roles must permit the write");
|
||||
var identity = rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldHaveSingleItem().EffectiveIdentity
|
||||
.ShouldBeOfType<RoleCarryingUserIdentity>();
|
||||
identity.Principal.ShouldBe(TrustedRig.Principal);
|
||||
identity.ApiKeyId.ShouldBe(TrustedRig.KeyId);
|
||||
identity.Roles.ShouldBeEmpty();
|
||||
|
||||
rig.Revoke();
|
||||
(await rig.WriteAsync(session, 99, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
rig.Writes.ShouldBe(1, "revocation must be checked before a cached narrow grant or dispatch");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OpcUaSecurityProfile.Basic256Sha256Sign)]
|
||||
[InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)]
|
||||
public async Task Trusted_channel_does_not_turn_invalid_credentials_into_another_identity(OpcUaSecurityProfile profile)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await TrustedRig.StartAsync(profile, ct);
|
||||
var error = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync("invalid", ct));
|
||||
error.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
rig.Writes.ShouldBe(0);
|
||||
rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldAllBe(s => !s.Activated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_untrusted_client_is_rejected_before_application_authentication()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await TrustedRig.StartAsync(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, ct);
|
||||
var before = rig.AuthCalls;
|
||||
var error = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync("contract-key", ct, untrustedClient: true));
|
||||
new[] { StatusCodes.BadCertificateUntrusted, StatusCodes.BadSecurityChecksFailed, StatusCodes.BadSecureChannelClosed }
|
||||
.ShouldContain(error.StatusCode);
|
||||
rig.AuthCalls.ShouldBe(before);
|
||||
rig.Writes.ShouldBe(0);
|
||||
}
|
||||
|
||||
private sealed class TrustedRig : IAsyncDisposable, IOpcUaUserAuthenticator, IOpcUaNodeWriteGateway
|
||||
{
|
||||
internal const string Principal = "trusted-contract";
|
||||
internal const string KeyId = "k-trusted-contract";
|
||||
private const string Cluster = "SDK2";
|
||||
private const string PathId = "Plant/Device/Value";
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otua-trusted-{Guid.NewGuid():N}");
|
||||
private readonly Dictionary<ApplicationConfiguration, ApplicationInstance> _applications = [];
|
||||
private readonly DataPlaneAuthorityState _authority = new();
|
||||
private readonly OpcUaApplicationHostOptions _options;
|
||||
private OpcUaApplicationHost _host = null!;
|
||||
private int _port;
|
||||
private int _writes;
|
||||
private int _authCalls;
|
||||
internal OtOpcUaSdkServer Server { get; private set; } = null!;
|
||||
internal int Writes => Volatile.Read(ref _writes);
|
||||
internal int AuthCalls => Volatile.Read(ref _authCalls);
|
||||
private string ServerPki => System.IO.Path.Combine(_root, "server");
|
||||
private string ClientPki => System.IO.Path.Combine(_root, "client");
|
||||
|
||||
private TrustedRig(OpcUaSecurityProfile profile)
|
||||
{
|
||||
_options = new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "Sdk2TrustedContract", ApplicationUri = $"urn:Sdk2TrustedContract:{Guid.NewGuid():N}",
|
||||
PublicHostname = "localhost", PkiStoreRoot = ServerPki, AllowAnonymous = false,
|
||||
AutoAcceptUntrustedClientCertificates = false, EnabledSecurityProfiles = [profile],
|
||||
};
|
||||
}
|
||||
|
||||
internal static async Task<TrustedRig> StartAsync(OpcUaSecurityProfile profile, CancellationToken ct)
|
||||
{
|
||||
var rig = new TrustedRig(profile);
|
||||
try
|
||||
{
|
||||
// Provision both certificates, then reload the managers with explicit trusted-peer stores.
|
||||
await rig.BootAsync(ct);
|
||||
await rig._host.DisposeAsync();
|
||||
var client = await rig.ConfigurationAsync(rig.ClientPki, ct);
|
||||
CopyCertificates(rig.ServerPki, rig.ClientPki);
|
||||
CopyCertificates(rig.ClientPki, rig.ServerPki);
|
||||
await rig.ReleaseConfigurationAsync(client);
|
||||
await rig.BootAsync(ct);
|
||||
rig.InstallAuthority(enabled: true);
|
||||
var nm = rig.Server.NodeManager!;
|
||||
nm.DataPlaneAuthority = rig._authority;
|
||||
nm.Authorizer = new DataPlaneAuthorizer(rig._authority, new TriePermissionEvaluator(rig._authority.Tries),
|
||||
true, NullLogger.Instance, TimeProvider.System);
|
||||
nm.NodeWriteGateway = rig;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
sink.EnsureFolder("Plant", null, "Plant", AddressSpaceRealm.Raw);
|
||||
sink.EnsureFolder("Plant/Device", "Plant", "Device", AddressSpaceRealm.Raw);
|
||||
sink.EnsureVariable(PathId, "Plant/Device", "Value", "Int32", true, AddressSpaceRealm.Raw);
|
||||
sink.WriteValue(PathId, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
return rig;
|
||||
}
|
||||
catch { await rig.DisposeAsync(); throw; }
|
||||
}
|
||||
|
||||
private async Task BootAsync(CancellationToken ct)
|
||||
{
|
||||
(_host, Server) = await OpcUaHostStart.StartAsync(port =>
|
||||
{
|
||||
_port = port; _options.OpcUaPort = port;
|
||||
return new OpcUaApplicationHost(_options, NullLogger<OpcUaApplicationHost>.Instance, this);
|
||||
}, () => new OtOpcUaSdkServer(), ct);
|
||||
}
|
||||
|
||||
private static void CopyCertificates(string from, string to)
|
||||
{
|
||||
var destination = System.IO.Path.Combine(to, "trusted", "certs");
|
||||
Directory.CreateDirectory(destination);
|
||||
foreach (var file in Directory.GetFiles(System.IO.Path.Combine(from, "own", "certs"), "*.der"))
|
||||
File.Copy(file, System.IO.Path.Combine(destination, System.IO.Path.GetFileName(file)), overwrite: true);
|
||||
}
|
||||
|
||||
private async Task<ApplicationConfiguration> ConfigurationAsync(string pki, CancellationToken ct)
|
||||
{
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var security = TestClientSecurity.Build(pki);
|
||||
security.AutoAcceptUntrustedCertificates = false;
|
||||
security.ApplicationCertificate!.SubjectName = "CN=Sdk2TrustedClient";
|
||||
var config = new ApplicationConfiguration(telemetry)
|
||||
{
|
||||
ApplicationName = "Sdk2TrustedClient", ApplicationUri = "urn:localhost:Sdk2TrustedClient",
|
||||
ApplicationType = ApplicationType.Client, SecurityConfiguration = security,
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
var app = new ApplicationInstance(telemetry)
|
||||
{ ApplicationName = config.ApplicationName, ApplicationType = ApplicationType.Client, ApplicationConfiguration = config };
|
||||
try
|
||||
{
|
||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
||||
await app.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 12, ct);
|
||||
_applications.Add(config, app);
|
||||
return config;
|
||||
}
|
||||
catch { await app.DisposeAsync(); throw; }
|
||||
}
|
||||
|
||||
private async Task ReleaseConfigurationAsync(ApplicationConfiguration config)
|
||||
{
|
||||
if (_applications.Remove(config, out var app)) await app.DisposeAsync();
|
||||
}
|
||||
|
||||
internal async Task<ISession> ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false)
|
||||
{
|
||||
var pki = untrustedClient ? System.IO.Path.Combine(_root, $"untrusted-{Guid.NewGuid():N}") : ClientPki;
|
||||
var config = await ConfigurationAsync(pki, ct);
|
||||
if (untrustedClient)
|
||||
{
|
||||
await ReleaseConfigurationAsync(config);
|
||||
CopyCertificates(ServerPki, pki);
|
||||
config = await ConfigurationAsync(pki, ct);
|
||||
}
|
||||
config.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeFalse();
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(config, $"opc.tcp://localhost:{_port}/OtOpcUa", true, telemetry, ct)
|
||||
?? throw new InvalidOperationException("No secure endpoint.");
|
||||
return await new DefaultSessionFactory(telemetry).CreateAsync(config,
|
||||
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, true,
|
||||
"Sdk2TrustedContract", 60_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), default, ct);
|
||||
}
|
||||
|
||||
internal NodeId Node(ISession session) => new(PathId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||||
internal async Task<StatusCode> WriteAsync(ISession session, int value, CancellationToken ct) =>
|
||||
(await session.WriteAsync(null, [new WriteValue { NodeId = Node(session), AttributeId = Attributes.Value,
|
||||
Value = new DataValue(Variant.From(value)) }], ct)).Results[0];
|
||||
internal void Revoke() => InstallAuthority(enabled: false);
|
||||
private void InstallAuthority(bool enabled) => _authority.Apply(Cluster, enabled ? "allowed" : "revoked",
|
||||
[new ApiKey { ApiKeyId = KeyId, Name = KeyId, Principal = Principal, Enabled = enabled,
|
||||
SecretHash = "hash", CreatedUtc = DateTime.UtcNow.AddDays(-1) }],
|
||||
[new NodeAcl { NodeAclId = "narrow", ClusterId = Cluster, Principal = Principal,
|
||||
ScopeKind = NodeAclScopeKind.RawDevice, ScopeId = "device", PermissionFlags =
|
||||
NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe | NodePermissions.WriteOperate }],
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{ [new(NodeAclScopeKind.RawDevice, "device")] = [new([NodeAclRealms.Raw, "Plant", "Device"])] });
|
||||
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _authCalls);
|
||||
return Task.FromResult(username == "apikey" && password == "contract-key"
|
||||
? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId) : OpcUaUserAuthResult.Deny("Invalid credential."));
|
||||
}
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
|
||||
{ Interlocked.Increment(ref _writes); return Task.FromResult(new NodeWriteOutcome(true, null)); }
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_host is not null) await _host.DisposeAsync();
|
||||
foreach (var app in _applications.Values) await app.DisposeAsync();
|
||||
_applications.Clear();
|
||||
if (Directory.Exists(_root)) Directory.Delete(_root, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
|
||||
public OtOpcUaNodeManager Nm => _server.NodeManager!;
|
||||
|
||||
internal OtOpcUaSdkServer Server => _server;
|
||||
|
||||
/// <summary>
|
||||
/// The roles the <c>apikey</c> session activates with (read at each activation). Defaults to the two
|
||||
/// server-wide roles; a flag-off test sets it from <c>ApiKeyInterimRoleMapper.Map</c> over its own grants, as
|
||||
|
||||
Reference in New Issue
Block a user