Skip to content

Commit 76b5318

Browse files
authored
ChannelToken: Dispose HMAC and improve lifetime calculations. (#2846)
- Channels are not disposed (client and server) - During long running tests an HMAC object leak was observed. - The HMAC and SymmetricSign objects were not disposed after use, leading to a small incremental memory leak per key renewal. - The channel token lifetime is calculated by the system clock, which can change. Instead use the continous HiResClock.TickCount to calculate the lifetimes. - Token renewal and connection requests are not removed from the request dictionary Improvements: - Make ChannelToken disposable. Dispose channels. - Dispose HMAC and SymmetricSign objects after use. - Use TickCount to calculate key renewal and key expiry. - Introduce a 5% jitter to the token renewal timer, to avoid token renewal storms. - Reduce the allocation per use of the HMAC objects by using ReadOnlySpan and avoid MemoryStream.
1 parent 56dd06d commit 76b5318

File tree

12 files changed

+438
-191
lines changed

12 files changed

+438
-191
lines changed

.editorconfig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,9 @@ dotnet_diagnostic.CA1819.severity =
342342
# CA1721: The property name is confusing given the existence of another method with the same name.
343343
dotnet_diagnostic.CA1721.severity = silent
344344

345+
# CA2014: Do not use stackalloc in loops
346+
dotnet_diagnostic.CA2014.severity = error
347+
345348
# exclude generated code
346349
[**/Generated/*.cs]
347350
generated_code = true

Libraries/Opc.Ua.Security.Certificates/Common/AsnUtils.cs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,24 +50,35 @@ internal static string ToHexString(this byte[] buffer, bool invertEndian = false
5050
return String.Empty;
5151
}
5252

53-
var builder = new StringBuilder(buffer.Length * 2);
54-
55-
if (invertEndian)
53+
#if NET6_0_OR_GREATER
54+
if (!invertEndian)
5655
{
57-
for (int ii = buffer.Length - 1; ii >= 0; ii--)
58-
{
59-
builder.AppendFormat(CultureInfo.InvariantCulture, "{0:X2}", buffer[ii]);
60-
}
56+
return Convert.ToHexString(buffer);
6157
}
6258
else
59+
#endif
6360
{
64-
for (int ii = 0; ii < buffer.Length; ii++)
61+
StringBuilder builder = new StringBuilder(buffer.Length * 2);
62+
63+
#if !NET6_0_OR_GREATER
64+
if (!invertEndian)
65+
{
66+
for (int ii = 0; ii < buffer.Length; ii++)
67+
{
68+
builder.AppendFormat(CultureInfo.InvariantCulture, "{0:X2}", buffer[ii]);
69+
}
70+
}
71+
else
72+
#endif
6573
{
66-
builder.AppendFormat(CultureInfo.InvariantCulture, "{0:X2}", buffer[ii]);
74+
for (int ii = buffer.Length - 1; ii >= 0; ii--)
75+
{
76+
builder.AppendFormat(CultureInfo.InvariantCulture, "{0:X2}", buffer[ii]);
77+
}
6778
}
68-
}
6979

70-
return builder.ToString();
80+
return builder.ToString();
81+
}
7182
}
7283

7384
/// <summary>
@@ -85,6 +96,9 @@ internal static byte[] FromHexString(this string buffer)
8596
return Array.Empty<byte>();
8697
}
8798

99+
#if NET6_0_OR_GREATER
100+
return Convert.FromHexString(buffer);
101+
#else
88102
const string digits = "0123456789ABCDEF";
89103

90104
byte[] bytes = new byte[(buffer.Length / 2) + (buffer.Length % 2)];
@@ -120,6 +134,7 @@ internal static byte[] FromHexString(this string buffer)
120134
}
121135

122136
return bytes;
137+
#endif
123138
}
124139

125140
/// <summary>

Stack/Opc.Ua.Core/Stack/Tcp/ChannelToken.cs

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
1111
*/
1212

1313
using System;
14+
using System.Diagnostics;
1415
using System.Security.Cryptography;
1516

1617
namespace Opc.Ua.Bindings
1718
{
1819
/// <summary>
1920
/// Represents a security token associate with a channel.
2021
/// </summary>
21-
public class ChannelToken
22+
public sealed class ChannelToken : IDisposable
2223
{
2324
#region Constructors
2425
/// <summary>
@@ -29,6 +30,49 @@ public ChannelToken()
2930
}
3031
#endregion
3132

33+
#region IDisposable
34+
/// <summary>
35+
/// The private version of the Dispose.
36+
/// </summary>
37+
private void Dispose(bool disposing)
38+
{
39+
if (!m_disposed)
40+
{
41+
if (disposing)
42+
{
43+
Utils.SilentDispose(m_clientHmac);
44+
Utils.SilentDispose(m_serverHmac);
45+
Utils.SilentDispose(m_clientEncryptor);
46+
Utils.SilentDispose(m_serverEncryptor);
47+
}
48+
m_clientHmac = null;
49+
m_serverHmac = null;
50+
m_clientEncryptor = null;
51+
m_serverEncryptor = null;
52+
m_disposed = true;
53+
}
54+
}
55+
56+
#if DEBUG
57+
/// <summary>
58+
/// The finalizer is used to catch issues with the dispose.
59+
/// </summary>
60+
~ChannelToken()
61+
{
62+
Dispose(disposing: false);
63+
}
64+
#endif
65+
66+
/// <summary>
67+
/// Disposes the channel tokens.
68+
/// </summary>
69+
public void Dispose()
70+
{
71+
Dispose(disposing: true);
72+
GC.SuppressFinalize(this);
73+
}
74+
#endregion
75+
3276
#region Public Properties
3377
/// <summary>
3478
/// The id assigned to the channel that the token belongs to.
@@ -57,6 +101,16 @@ public DateTime CreatedAt
57101
set { m_createdAt = value; }
58102
}
59103

104+
/// <summary>
105+
/// When the token was created (refers to the local tick count).
106+
/// Used for calculation of renewals. Uses <see cref="Opc.Ua.HiResClock.TickCount"/>.
107+
/// </summary>
108+
public int CreatedAtTickCount
109+
{
110+
get { return m_createdAtTickCount; }
111+
set { m_createdAtTickCount = value; }
112+
}
113+
60114
/// <summary>
61115
/// The lifetime of the token in milliseconds.
62116
/// </summary>
@@ -73,12 +127,7 @@ public bool Expired
73127
{
74128
get
75129
{
76-
if (DateTime.UtcNow > m_createdAt.AddMilliseconds(m_lifetime))
77-
{
78-
return true;
79-
}
80-
81-
return false;
130+
return (HiResClock.TickCount - m_createdAtTickCount) > m_lifetime;
82131
}
83132
}
84133

@@ -89,12 +138,7 @@ public bool ActivationRequired
89138
{
90139
get
91140
{
92-
if (DateTime.UtcNow > m_createdAt.AddMilliseconds(m_lifetime * TcpMessageLimits.TokenActivationPeriod))
93-
{
94-
return true;
95-
}
96-
97-
return false;
141+
return (HiResClock.TickCount - m_createdAtTickCount) > (int)Math.Round(m_lifetime * TcpMessageLimits.TokenActivationPeriod);
98142
}
99143
}
100144

@@ -213,12 +257,13 @@ public HMAC ServerHmac
213257
get { return m_serverHmac; }
214258
set { m_serverHmac = value; }
215259
}
216-
#endregion
260+
#endregion
217261

218262
#region Private Fields
219263
private uint m_channelId;
220264
private uint m_tokenId;
221265
private DateTime m_createdAt;
266+
private int m_createdAtTickCount;
222267
private int m_lifetime;
223268
private byte[] m_clientNonce;
224269
private byte[] m_serverNonce;
@@ -232,6 +277,7 @@ public HMAC ServerHmac
232277
private HMAC m_serverHmac;
233278
private SymmetricAlgorithm m_clientEncryptor;
234279
private SymmetricAlgorithm m_serverEncryptor;
280+
private bool m_disposed;
235281
#endregion
236282
}
237283
}

Stack/Opc.Ua.Core/Stack/Tcp/TcpMessageType.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ public static class TcpMessageLimits
298298
/// </summary>
299299
public const double TokenRenewalPeriod = 0.75;
300300

301+
/// <summary>
302+
/// The fraction of the lifetime to jitter renewing a token.
303+
/// </summary>
304+
public const double TokenRenewalJitterPeriod = 0.05;
305+
301306
/// <summary>
302307
/// The fraction of the lifetime to wait before forcing the activation of the renewed token.
303308
/// </summary>

Stack/Opc.Ua.Core/Stack/Tcp/TcpServerChannel.cs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ public TcpServerChannel(
6767
/// </summary>
6868
protected override void Dispose(bool disposing)
6969
{
70-
base.Dispose(disposing);
70+
lock (DataLock)
71+
{
72+
base.Dispose(disposing);
73+
}
7174
}
7275
#endregion
7376

@@ -549,6 +552,7 @@ private bool ProcessOpenSecureChannelRequest(uint messageType, ArraySegment<byte
549552

550553
BufferCollection chunksToProcess = null;
551554
OpenSecureChannelRequest request = null;
555+
ChannelToken token = null;
552556
try
553557
{
554558
bool firstCall = ClientCertificate == null;
@@ -589,13 +593,12 @@ private bool ProcessOpenSecureChannelRequest(uint messageType, ArraySegment<byte
589593
}
590594

591595
// create a new token.
592-
ChannelToken token = CreateToken();
593-
596+
token = CreateToken();
594597
token.TokenId = GetNewTokenId();
595598
token.ServerNonce = CreateNonce();
599+
596600
// check the client nonce.
597601
token.ClientNonce = request.ClientNonce;
598-
599602
if (!ValidateNonce(token.ClientNonce))
600603
{
601604
throw ServiceResultException.Create(StatusCodes.BadNonceInvalid, "Client nonce is not the correct length or not random enough.");
@@ -637,6 +640,8 @@ private bool ProcessOpenSecureChannelRequest(uint messageType, ArraySegment<byte
637640
token,
638641
request);
639642

643+
token = null;
644+
640645
Utils.LogInfo(
641646
"{0} ReconnectToExistingChannel Socket={1:X8}, ChannelId={2}, TokenId={3}",
642647
ChannelName,
@@ -688,10 +693,13 @@ private bool ProcessOpenSecureChannelRequest(uint messageType, ArraySegment<byte
688693
ActivateToken(token);
689694
}
690695

696+
// ensure the token is not disposed
697+
token = null;
698+
691699
State = TcpChannelState.Open;
692700

693701
// send the response.
694-
SendOpenSecureChannelResponse(requestId, token, request);
702+
SendOpenSecureChannelResponse(requestId, CurrentToken, request);
695703

696704
// notify reverse
697705
CompleteReverseHello(null);
@@ -718,6 +726,7 @@ private bool ProcessOpenSecureChannelRequest(uint messageType, ArraySegment<byte
718726
}
719727
finally
720728
{
729+
Utils.SilentDispose(token);
721730
if (chunksToProcess != null)
722731
{
723732
chunksToProcess.Release(BufferManager, "ProcessOpenSecureChannelRequest");
@@ -1118,7 +1127,7 @@ protected override void DoMessageLimitsExceeded()
11181127
private bool ValidateDiscoveryServiceCall(ChannelToken token, uint requestId, ArraySegment<byte> messageBody, out BufferCollection chunksToProcess)
11191128
{
11201129
chunksToProcess = null;
1121-
using (var decoder = new BinaryDecoder(messageBody.AsMemory().ToArray(), Quotas.MessageContext))
1130+
using (var decoder = new BinaryDecoder(messageBody, Quotas.MessageContext))
11221131
{
11231132
// read the type of the message before more chunks are processed.
11241133
NodeId typeId = decoder.ReadNodeId(null);
@@ -1134,7 +1143,6 @@ private bool ValidateDiscoveryServiceCall(ChannelToken token, uint requestId, Ar
11341143
return true;
11351144
}
11361145
}
1137-
11381146
#endregion
11391147

11401148
#region Private Fields

Stack/Opc.Ua.Core/Stack/Tcp/TcpTransportListener.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,10 @@ public bool ReconnectToExistingChannel(
241241
/// </summary>
242242
public void ChannelClosed(uint channelId)
243243
{
244-
if (m_channels?.TryRemove(channelId, out _) == true)
244+
TcpListenerChannel channel = null;
245+
if (m_channels?.TryRemove(channelId, out channel) == true)
245246
{
247+
Utils.SilentDispose(channel);
246248
Utils.LogInfo("ChannelId {0}: closed", channelId);
247249
}
248250
else
@@ -307,11 +309,17 @@ private void OnReverseHelloComplete(IAsyncResult result)
307309
channel.SetReportCloseSecureChannelAuditCallback(new ReportAuditCloseSecureChannelEventHandler(OnReportAuditCloseSecureChannelEvent));
308310
channel.SetReportCertificateAuditCallback(new ReportAuditCertificateEventHandler(OnReportAuditCertificateEvent));
309311
}
312+
313+
channel = null;
310314
}
311315
catch (Exception e)
312316
{
313317
ConnectionStatusChanged?.Invoke(this, new ConnectionStatusEventArgs(channel.ReverseConnectionUrl, new ServiceResult(e), true));
314318
}
319+
finally
320+
{
321+
Utils.SilentDispose(channel);
322+
}
315323
}
316324
#endregion
317325

@@ -533,6 +541,7 @@ private void OnAccept(object sender, SocketAsyncEventArgs e)
533541
// check if the accept socket has been created.
534542
if (serveChannel && e.AcceptSocket != null && e.SocketError == SocketError.Success)
535543
{
544+
channel = null;
536545
try
537546
{
538547
if (m_reverseConnectListener)
@@ -578,11 +587,17 @@ private void OnAccept(object sender, SocketAsyncEventArgs e)
578587

579588
// start accepting messages on the channel.
580589
channel.Attach(channelId, e.AcceptSocket);
590+
591+
channel = null;
581592
}
582593
catch (Exception ex)
583594
{
584595
Utils.LogError(ex, "Unexpected error accepting a new connection.");
585596
}
597+
finally
598+
{
599+
Utils.SilentDispose(channel);
600+
}
586601
}
587602
}
588603

0 commit comments

Comments
 (0)