Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs
T
Joseph Doherty be28aa4432 fix(opcua-client): send UserName passwords in the legacy RSA format so 65-char API keys log in to SDK 1.5 servers (#880)
SDK 2.0.1's UserNameIdentityTokenHandler encrypts a password longer than 64 bytes
(a private RsaEncryptedSecretPasswordThreshold, no setting) as an RsaEncryptedSecret
with EncryptionAlgorithm=null. SDK 1.5.378 (.106 and .182) decrypts only the legacy
format under RSA policies; its SecurityPolicies.Decrypt answers a null algorithm with
BadIdentityTokenInvalid ("Unexpected encryption algorithm") before any key check.
Every production API key is 65 chars, so no SDK 2 OtOpcUa client could use one
against QA (b8b4c18f), a rollback target or an older third-party server.

LegacyRsaUserNameIdentityTokenHandler (Driver.OpcUaClient.Session, plugged in through
the public UserIdentity(IUserIdentityTokenHandler) ctor) always uses the legacy
format, RSA-OAEP(len|password|serverNonce) under the server certificate, under an RSA
policy. That is what SDK 2 sends for passwords of 64 bytes or less and what SDK 1.5
clients always send. The legacy format has no size limit (the plaintext is
length-framed over as many RSA blocks as it needs), and an SDK 2 server reads both
formats. Policy None, the ECC policies and a missing certificate go to the SDK
handler unchanged.

OpcUaUserIdentityFactory.UserName is now the one UserName identity the driver, the
AdminUI browse and Client.Shared (CLI/UI) present. Client.Shared references .Session
for it.

Tests: handler unit tests (all three RSA policies, the 1.5-required algorithm URI,
SDK 2 server-side decrypt, the session Copy() keeps the type, None unchanged, and an
SDK control pinning the 64/65-byte switch); a Client.Shared identity test; and an
SDK2<->SDK2 integration test where a 65-char key is admitted in both formats and a
wrong 65-char key reaches the key check (BadIdentityTokenRejected). The #800 Windows
gate's SDK 2 CLI 65-char case now gates against the 1.5 server too.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 11:12:27 -05:00

373 lines
22 KiB
C#

using System.Text;
using System.Net;
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
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 observedPeer = rig.Peers.ShouldHaveSingleItem();
observedPeer.ShouldNotBeNull();
IPAddress.IsLoopback(observedPeer.Address).ShouldBeTrue();
observedPeer.Port.ShouldBe(0, "the public SDK evidence supplies an IP address, not an invented client port");
var originalSessionId = session.SessionId;
await session.ReconnectAsync(null, null, ct);
session.SessionId.ShouldBe(originalSessionId, "the test must reactivate the existing session on a new channel");
rig.Peers.Count.ShouldBe(2);
rig.Peers.Last().ShouldNotBeNull().Address.ShouldBe(observedPeer.Address);
(await session.ReadValueAsync(rig.Node(session), ct)).StatusCode.ShouldBe(StatusCodes.Good);
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 Concurrent_IPv4_and_IPv6_activations_keep_their_own_transport_peer(OpcUaSecurityProfile profile)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await TrustedRig.StartAsync(profile, ct);
rig.HoldPeerActivations = true;
// Distinct test attempt markers share one key identity and trusted certificate. Both auth flows await the barrier.
var firstConnection = rig.ConnectAsync("contract-key-ipv4", ct, transportHost: "127.0.0.1");
var secondConnection = rig.ConnectAsync("contract-key-ipv6", ct, transportHost: "[::1]");
ISession[] connections;
try { connections = await Task.WhenAll(firstConnection, secondConnection); }
catch
{
if (firstConnection.IsCompletedSuccessfully) (await firstConnection).Dispose();
if (secondConnection.IsCompletedSuccessfully) (await secondConnection).Dispose();
throw;
}
using var ipv4 = connections[0];
using var ipv6 = connections[1];
rig.Peers.Count.ShouldBe(2);
var first4 = rig.PeerObservations.Single(x => x.Attempt == "IPv4");
var first6 = rig.PeerObservations.Single(x => x.Attempt == "IPv6");
first4.Peer.ShouldNotBeNull().Address.ShouldBe(IPAddress.Loopback);
first6.Peer.ShouldNotBeNull().Address.ShouldBe(IPAddress.IPv6Loopback);
first4.AfterAwaitPeer.ShouldBe(first4.Peer.Address);
first6.AfterAwaitPeer.ShouldBe(first6.Peer.Address);
foreach (var session in connections)
(await session.ReadValueAsync(rig.Node(session), ct)).StatusCode.ShouldBe(StatusCodes.Good);
await ipv4.ReconnectAsync(null, null, ct);
rig.Peers.Count.ShouldBe(3);
rig.Peers.Last().ShouldNotBeNull().Address.ShouldBe(IPAddress.Loopback,
"reactivation cannot inherit the concurrent IPv6 activation's peer");
await ipv6.CloseAsync(ct);
await ipv4.CloseAsync(ct);
}
[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);
}
/// <summary>
/// #880: an SDK 2 server reads a production-length (65-char) API key in both UserName password formats, the
/// legacy one OtOpcUa's clients now always send under RSA policies (so SDK 1.5 servers can read it too) and
/// SDK 2's default RsaEncryptedSecret. A wrong key in the legacy format reaches the real key check
/// (BadIdentityTokenRejected, not a format failure), so the token is decrypted, not just tolerated.
/// The SDK 1.5 server half is the separate-process matrix (scripts/validation/opcua-sdk-compatibility).
/// </summary>
[Theory]
[InlineData(OpcUaSecurityProfile.Basic256Sha256Sign)]
[InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)]
public async Task A_production_length_API_key_is_admitted_in_either_password_format(OpcUaSecurityProfile profile)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await TrustedRig.StartAsync(profile, ct);
TrustedRig.LongKey.Length.ShouldBe(65);
using (var legacy = await rig.ConnectAsync(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.OpcUaUserIdentityFactory
.UserName("apikey", TrustedRig.LongKey), ct))
(await legacy.ReadValueAsync(rig.Node(legacy), ct)).StatusCode.ShouldBe(StatusCodes.Good);
using (var sdkDefault = await rig.ConnectAsync(
new UserIdentity("apikey", Encoding.UTF8.GetBytes(TrustedRig.LongKey)), ct))
(await sdkDefault.ReadValueAsync(rig.Node(sdkDefault), ct)).StatusCode.ShouldBe(StatusCodes.Good);
var before = rig.AuthCalls;
var wrong = TrustedRig.LongKey[..^1] + (TrustedRig.LongKey[^1] == 'A' ? 'B' : 'A');
var error = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync(
ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.OpcUaUserIdentityFactory.UserName("apikey", wrong), ct));
error.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
rig.AuthCalls.ShouldBe(before + 1, "the wrong key must reach the authenticator, i.e. decrypt cleanly");
}
[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);
}
/// <summary>
/// #874: SDK-side refusals used to leave no server log line (the host gave the SDK a provider-less telemetry
/// context). With <see cref="ServerSdkTelemetry"/> wired, rejecting an untrusted client is logged at Warning+.
/// </summary>
[Fact]
public async Task An_sdk_side_refusal_reaches_the_server_log()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await TrustedRig.StartAsync(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, ct);
rig.SdkLog.Clear();
await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync("contract-key", ct, untrustedClient: true));
var deadline = DateTime.UtcNow.AddSeconds(5);
while (rig.SdkLog.IsEmpty && DateTime.UtcNow < deadline) await Task.Delay(50, ct);
var entries = rig.SdkLog.ToArray();
entries.ShouldNotBeEmpty("the SDK's refusal must reach the host's logger");
entries.ShouldAllBe(e => e.Level >= LogLevel.Warning);
TestContext.Current.SendDiagnosticMessage(string.Join("\n", entries.Select(e => $"{e.Level} {e.Category}: {e.Text}")));
}
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 ConcurrentDictionary<ApplicationConfiguration, ApplicationInstance> _applications = new();
private readonly DataPlaneAuthorityState _authority = new();
private readonly OpcUaApplicationHostOptions _options;
private OpcUaApplicationHost _host = null!;
private int _port;
private int _writes;
private int _authCalls;
internal sealed record PeerObservation(string Attempt, IPEndPoint? Peer, IPAddress? AfterAwaitPeer);
internal ConcurrentQueue<PeerObservation> PeerObservations { get; } = new();
internal IReadOnlyList<IPEndPoint?> Peers => PeerObservations.Select(x => x.Peer).ToArray();
internal bool HoldPeerActivations { get; set; }
private readonly TaskCompletionSource _peerBarrier = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _peerBarrierArrivals;
internal OtOpcUaSdkServer Server { get; private set; } = null!;
internal ConcurrentQueue<(LogLevel Level, string Category, string Text)> SdkLog { get; } = new();
private readonly ILoggerFactory _sdkLoggerFactory;
private readonly ITelemetryContext _sdkTelemetry;
/// <summary>#880: a random key in the production ApiKeySecret.Generate shape (65 chars, over SDK 2's 64-byte threshold).</summary>
internal static readonly string LongKey = $"otua_{Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(8)).ToLowerInvariant()}_"
+ Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
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],
};
_sdkLoggerFactory = LoggerFactory.Create(b => b.SetMinimumLevel(LogLevel.Trace).AddProvider(new CaptureProvider(SdkLog)));
_sdkTelemetry = ServerSdkTelemetry.Create(_sdkLoggerFactory);
}
private sealed class CaptureProvider(ConcurrentQueue<(LogLevel, string, string)> sink) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => new Capture(sink, categoryName);
public void Dispose() { }
private sealed class Capture(ConcurrentQueue<(LogLevel, string, string)> sink, string category) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
sink.Enqueue((logLevel, category, formatter(state, exception) + (exception is null ? "" : " | " + exception.Message)));
}
}
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, _sdkTelemetry);
}, () => new OtOpcUaSdkServer(telemetry: _sdkTelemetry), 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[config] = app;
return config;
}
catch { await app.DisposeAsync(); throw; }
}
private async Task ReleaseConfigurationAsync(ApplicationConfiguration config)
{
if (_applications.TryRemove(config, out var app)) await app.DisposeAsync();
}
internal Task<ISession> ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false, string? transportHost = null) =>
ConnectAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), ct, untrustedClient, transportHost);
internal async Task<ISession> ConnectAsync(UserIdentity identity, CancellationToken ct, bool untrustedClient = false, string? transportHost = null)
{
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.");
if (transportHost is not null) endpoint.EndpointUrl = $"opc.tcp://{transportHost}:{_port}/OtOpcUa";
return await new DefaultSessionFactory(telemetry).CreateAsync(config,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, true,
"Sdk2TrustedContract", 60_000, identity, 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 async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, IPEndPoint? peer, CancellationToken ct)
{
var attempt = password switch { "contract-key-ipv4" => "IPv4", "contract-key-ipv6" => "IPv6", _ => "Other" };
if (HoldPeerActivations && attempt != "Other")
{
if (Interlocked.Increment(ref _peerBarrierArrivals) == 2) _peerBarrier.TrySetResult();
await _peerBarrier.Task.WaitAsync(TimeSpan.FromSeconds(15), ct);
}
PeerObservations.Enqueue(new(attempt, peer is null ? null : new IPEndPoint(peer.Address, peer.Port), Server.ActivationPeerAddress));
return await AuthenticateUserNameAsync(username, password, ct);
}
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
{
Interlocked.Increment(ref _authCalls);
return Task.FromResult(username == "apikey" && (password is "contract-key" or "contract-key-ipv4" or "contract-key-ipv6" || password == LongKey)
? 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();
ServerSdkTelemetry.Release(_sdkTelemetry);
_sdkLoggerFactory.Dispose();
if (Directory.Exists(_root)) Directory.Delete(_root, true);
}
}
}