From 569f68968f7fb2b7062ee193312c4328c8d61bcc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 23:01:38 +0000 Subject: [PATCH 1/5] Initial plan From de2fb707a5bef8525164dbd09a393faa5596986f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 23:07:25 +0000 Subject: [PATCH 2/5] Add CurveAllowedClients feature to enable server-side client public key whitelist Agent-Logs-Url: https://github.com/zeromq/netmq/sessions/f166bdbe-9fb4-4148-ad85-7eb065157838 Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- src/NetMQ.Tests/CurveTests.cs | 53 +++++++++++++++++++ .../Core/Mechanisms/CurveServerMechanism.cs | 4 ++ src/NetMQ/Core/Options.cs | 22 ++++++++ src/NetMQ/Core/ZmqSocketOption.cs | 8 ++- src/NetMQ/SocketOptions.cs | 12 +++++ src/NetMQ/ThreadSafeSocketOptions.cs | 12 +++++ 6 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/NetMQ.Tests/CurveTests.cs b/src/NetMQ.Tests/CurveTests.cs index f693aec3..e363333a 100644 --- a/src/NetMQ.Tests/CurveTests.cs +++ b/src/NetMQ.Tests/CurveTests.cs @@ -1,3 +1,4 @@ +using System; using System.Net; using System.Net.Sockets; using System.Text; @@ -40,6 +41,58 @@ public void CurveTest() } + + [Fact] + public void CurveAllowedClientsTest_AllowedClientCanConnect() + { + var serverPair = new NetMQCertificate(); + using var server = new DealerSocket(); + server.Options.CurveServer = true; + server.Options.CurveCertificate = serverPair; + + var allowedClientPair = new NetMQCertificate(); + // Only allow this specific client + server.Options.CurveAllowedClients.Add(allowedClientPair.PublicKey); + + int port = server.BindRandomPort("tcp://127.0.0.1"); + + using var client = new DealerSocket(); + client.Options.CurveServerKey = serverPair.PublicKey; + client.Options.CurveCertificate = allowedClientPair; + client.Connect($"tcp://127.0.0.1:{port}"); + + client.SendFrame("Hello"); + var hello = server.ReceiveFrameString(); + Assert.Equal("Hello", hello); + } + + [Fact] + public void CurveAllowedClientsTest_BlockedClientCannotConnect() + { + var serverPair = new NetMQCertificate(); + using var server = new DealerSocket(); + server.Options.CurveServer = true; + server.Options.CurveCertificate = serverPair; + + var allowedClientPair = new NetMQCertificate(); + // Only allow this specific client + server.Options.CurveAllowedClients.Add(allowedClientPair.PublicKey); + + int port = server.BindRandomPort("tcp://127.0.0.1"); + + // Connect with a different (not allowed) client + var blockedClientPair = new NetMQCertificate(); + using var blockedClient = new DealerSocket(); + blockedClient.Options.CurveServerKey = serverPair.PublicKey; + blockedClient.Options.CurveCertificate = blockedClientPair; + blockedClient.Connect($"tcp://127.0.0.1:{port}"); + + blockedClient.TrySendFrame(TimeSpan.FromMilliseconds(100), "ShouldBeBlocked"); + + // Server should not receive anything from the blocked client + var received = server.TryReceiveFrameString(TimeSpan.FromMilliseconds(500), out var message); + Assert.False(received, "Blocked client should not have been able to send a message"); + } #if NETFRAMEWORK [Fact] diff --git a/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs b/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs index 92aaf5e0..1371fb05 100644 --- a/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs +++ b/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs @@ -267,6 +267,10 @@ PushMsgResult ProcessInitiate(ref Msg msg) if (!SpanUtility.Equals(vouchPlaintext.Slice(0, 32), m_cnClientKey)) return PushMsgResult.Error; + // Verify client long-term public key is in the allowed clients list (if configured) + if (Options.CurveAllowedClients.Count > 0 && !Options.CurveAllowedClients.Contains(clientKey.ToArray())) + return PushMsgResult.Error; + // Create the session box m_box = new Curve25519XSalsa20Poly1305(m_cnSecretKey, m_cnClientKey); diff --git a/src/NetMQ/Core/Options.cs b/src/NetMQ/Core/Options.cs index 5cac3c3d..79985187 100644 --- a/src/NetMQ/Core/Options.cs +++ b/src/NetMQ/Core/Options.cs @@ -21,7 +21,9 @@ You should have received a copy of the GNU Lesser General Public License */ using System; +using System.Collections.Generic; using System.Text; +using NetMQ.Core.Utils; namespace NetMQ.Core { @@ -302,6 +304,12 @@ public byte IdentitySize { /// public byte[] CurveServerKey { get; set; } + /// + /// The set of allowed client long-term public keys for a CURVE server socket. + /// When non-empty, only clients whose long-term public key is present in this set will be allowed to connect. + /// + public HashSet CurveAllowedClients { get; } = new HashSet(new ByteArrayEqualityComparer()); + /// /// If remote peer receives a PING message and doesn't receive another /// message within the ttl value, it should close the connection @@ -529,6 +537,17 @@ public void SetSocketOption(ZmqSocketOption option, object? optionValue) break; } + case ZmqSocketOption.CurveAllowedClients: + { + var key = Get(); + if (key.Length != 32) + throw new InvalidException("Curve key size must be 32 bytes"); + var keyCopy = new byte[32]; + Buffer.BlockCopy(key, 0, keyCopy, 0, 32); + CurveAllowedClients.Add(keyCopy); + break; + } + case ZmqSocketOption.HelloMessage: { if (optionValue == null) @@ -700,6 +719,9 @@ public void SetSocketOption(ZmqSocketOption option, object? optionValue) case ZmqSocketOption.CurveServerKey: return CurveServerKey; + case ZmqSocketOption.CurveAllowedClients: + return CurveAllowedClients; + default: throw new InvalidException("GetSocketOption called with invalid ZmqSocketOption of " + option); } diff --git a/src/NetMQ/Core/ZmqSocketOption.cs b/src/NetMQ/Core/ZmqSocketOption.cs index 43b8aab4..bc47b8c3 100644 --- a/src/NetMQ/Core/ZmqSocketOption.cs +++ b/src/NetMQ/Core/ZmqSocketOption.cs @@ -360,6 +360,12 @@ internal enum ZmqSocketOption PgmMaxTransportServiceDataUnitLength = 1001, Correlate = 58, // in zmq it's 52 and 53, but I guess it does not matter so long as it is unique. - Relaxed = 59 + Relaxed = 59, + + /// + /// The set of allowed client public keys for a CURVE server socket. + /// When non-empty, only clients whose long-term public key is present in this set will be allowed to connect. + /// + CurveAllowedClients = 61 } } \ No newline at end of file diff --git a/src/NetMQ/SocketOptions.cs b/src/NetMQ/SocketOptions.cs index de520e68..f26310ae 100644 --- a/src/NetMQ/SocketOptions.cs +++ b/src/NetMQ/SocketOptions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using NetMQ.Core; @@ -475,6 +476,17 @@ public NetMQCertificate CurveServerCertificate { set => m_socket.SetSocketOption(ZmqSocketOption.CurveServerKey, value.PublicKey); } + + /// + /// Gets the set of allowed client public keys for a CURVE server socket. + /// When non-empty, only clients whose long-term public key is present in this set will be allowed to connect. + /// Add public keys to restrict which clients can connect: + /// + /// server.Options.CurveAllowedClients.Add(clientCertificate.PublicKey); + /// + /// + public HashSet CurveAllowedClients => + m_socket.GetSocketOptionX>(ZmqSocketOption.CurveAllowedClients)!; /// /// If remote peer receives a PING message and doesn't receive another diff --git a/src/NetMQ/ThreadSafeSocketOptions.cs b/src/NetMQ/ThreadSafeSocketOptions.cs index fcfd8568..dea11ed1 100644 --- a/src/NetMQ/ThreadSafeSocketOptions.cs +++ b/src/NetMQ/ThreadSafeSocketOptions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using NetMQ.Core; @@ -368,6 +369,17 @@ public NetMQCertificate CurveServerCertificate { set => SetSocketOption(ZmqSocketOption.CurveServerKey, value.PublicKey); } + + /// + /// Gets the set of allowed client public keys for a CURVE server socket. + /// When non-empty, only clients whose long-term public key is present in this set will be allowed to connect. + /// Add public keys to restrict which clients can connect: + /// + /// server.Options.CurveAllowedClients.Add(clientCertificate.PublicKey); + /// + /// + public HashSet CurveAllowedClients => + GetSocketOptionX>(ZmqSocketOption.CurveAllowedClients)!; /// /// If remote peer receives a PING message and doesn't receive another From 8bdb954c39b7dd41c9ab49d2db113654eac36f19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 23:10:02 +0000 Subject: [PATCH 3/5] Address code review: use pre-allocated byte[] for client key to avoid ToArray() allocation Agent-Logs-Url: https://github.com/zeromq/netmq/sessions/f166bdbe-9fb4-4148-ad85-7eb065157838 Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs b/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs index 1371fb05..04b6a613 100644 --- a/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs +++ b/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs @@ -253,7 +253,8 @@ PushMsgResult ProcessInitiate(ref Msg msg) Span vouchNonce = stackalloc byte[Curve25519XSalsa20Poly1305.NonceLength]; Span vouchPlaintext = stackalloc byte[64]; Span vouchBox = new Span(initiatePlaintext, 48, 80); - var clientKey = new Span(initiatePlaintext, 0, 32); + var clientKey = new byte[32]; + Buffer.BlockCopy(initiatePlaintext, 0, clientKey, 0, 32); VouchNoncePrefix.CopyTo(vouchNonce); new Span(initiatePlaintext, 32, 16).CopyTo(vouchNonce.Slice(8)); @@ -268,7 +269,7 @@ PushMsgResult ProcessInitiate(ref Msg msg) return PushMsgResult.Error; // Verify client long-term public key is in the allowed clients list (if configured) - if (Options.CurveAllowedClients.Count > 0 && !Options.CurveAllowedClients.Contains(clientKey.ToArray())) + if (Options.CurveAllowedClients.Count > 0 && !Options.CurveAllowedClients.Contains(clientKey)) return PushMsgResult.Error; // Create the session box From 6e39e1b6d1a1d5a682ea4aeef82bbf9c1b073acf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 23:12:32 +0000 Subject: [PATCH 4/5] Revert to Span for clientKey; only allocate byte[] when whitelist is non-empty Agent-Logs-Url: https://github.com/zeromq/netmq/sessions/f166bdbe-9fb4-4148-ad85-7eb065157838 Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- src/NetMQ.Tests/CurveTests.cs | 2 ++ src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/NetMQ.Tests/CurveTests.cs b/src/NetMQ.Tests/CurveTests.cs index e363333a..9f2345ec 100644 --- a/src/NetMQ.Tests/CurveTests.cs +++ b/src/NetMQ.Tests/CurveTests.cs @@ -87,6 +87,8 @@ public void CurveAllowedClientsTest_BlockedClientCannotConnect() blockedClient.Options.CurveCertificate = blockedClientPair; blockedClient.Connect($"tcp://127.0.0.1:{port}"); + // The send may or may not succeed at the transport level (the handshake failure + // happens asynchronously), but the server must never deliver the message. blockedClient.TrySendFrame(TimeSpan.FromMilliseconds(100), "ShouldBeBlocked"); // Server should not receive anything from the blocked client diff --git a/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs b/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs index 04b6a613..2f5da823 100644 --- a/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs +++ b/src/NetMQ/Core/Mechanisms/CurveServerMechanism.cs @@ -253,8 +253,7 @@ PushMsgResult ProcessInitiate(ref Msg msg) Span vouchNonce = stackalloc byte[Curve25519XSalsa20Poly1305.NonceLength]; Span vouchPlaintext = stackalloc byte[64]; Span vouchBox = new Span(initiatePlaintext, 48, 80); - var clientKey = new byte[32]; - Buffer.BlockCopy(initiatePlaintext, 0, clientKey, 0, 32); + var clientKey = new Span(initiatePlaintext, 0, 32); VouchNoncePrefix.CopyTo(vouchNonce); new Span(initiatePlaintext, 32, 16).CopyTo(vouchNonce.Slice(8)); @@ -268,8 +267,9 @@ PushMsgResult ProcessInitiate(ref Msg msg) if (!SpanUtility.Equals(vouchPlaintext.Slice(0, 32), m_cnClientKey)) return PushMsgResult.Error; - // Verify client long-term public key is in the allowed clients list (if configured) - if (Options.CurveAllowedClients.Count > 0 && !Options.CurveAllowedClients.Contains(clientKey)) + // Verify client long-term public key is in the allowed clients list (if configured). + // The byte[] is only allocated when the whitelist is non-empty. + if (Options.CurveAllowedClients.Count > 0 && !Options.CurveAllowedClients.Contains(clientKey.ToArray())) return PushMsgResult.Error; // Create the session box From 2f44dd22a7a92be7f0f18eddee83c326fd1f23b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 13:35:47 +0000 Subject: [PATCH 5/5] Remove CurveAllowedClients from ZmqSocketOption; expose directly via SocketBase Agent-Logs-Url: https://github.com/zeromq/netmq/sessions/4c5608bd-8fa2-4db2-af6d-711ce7c90b0e Co-authored-by: drewnoakes <350947+drewnoakes@users.noreply.github.com> --- src/NetMQ/Core/Options.cs | 14 -------------- src/NetMQ/Core/SocketBase.cs | 6 ++++++ src/NetMQ/Core/ZmqSocketOption.cs | 8 +------- src/NetMQ/SocketOptions.cs | 2 +- src/NetMQ/ThreadSafeSocketOptions.cs | 2 +- 5 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/NetMQ/Core/Options.cs b/src/NetMQ/Core/Options.cs index 79985187..514dba0b 100644 --- a/src/NetMQ/Core/Options.cs +++ b/src/NetMQ/Core/Options.cs @@ -537,17 +537,6 @@ public void SetSocketOption(ZmqSocketOption option, object? optionValue) break; } - case ZmqSocketOption.CurveAllowedClients: - { - var key = Get(); - if (key.Length != 32) - throw new InvalidException("Curve key size must be 32 bytes"); - var keyCopy = new byte[32]; - Buffer.BlockCopy(key, 0, keyCopy, 0, 32); - CurveAllowedClients.Add(keyCopy); - break; - } - case ZmqSocketOption.HelloMessage: { if (optionValue == null) @@ -719,9 +708,6 @@ public void SetSocketOption(ZmqSocketOption option, object? optionValue) case ZmqSocketOption.CurveServerKey: return CurveServerKey; - case ZmqSocketOption.CurveAllowedClients: - return CurveAllowedClients; - default: throw new InvalidException("GetSocketOption called with invalid ZmqSocketOption of " + option); } diff --git a/src/NetMQ/Core/SocketBase.cs b/src/NetMQ/Core/SocketBase.cs index 6bc1db50..f739b054 100644 --- a/src/NetMQ/Core/SocketBase.cs +++ b/src/NetMQ/Core/SocketBase.cs @@ -480,6 +480,12 @@ public int GetSocketOption(ZmqSocketOption option) } } + /// + /// Gets the set of allowed client long-term public keys for a CURVE server socket. + /// When non-empty, only clients whose long-term public key is present in this set will be allowed to connect. + /// + internal System.Collections.Generic.HashSet CurveAllowedClients => m_options.CurveAllowedClients; + /// /// Join the dish socket to a group /// diff --git a/src/NetMQ/Core/ZmqSocketOption.cs b/src/NetMQ/Core/ZmqSocketOption.cs index bc47b8c3..43b8aab4 100644 --- a/src/NetMQ/Core/ZmqSocketOption.cs +++ b/src/NetMQ/Core/ZmqSocketOption.cs @@ -360,12 +360,6 @@ internal enum ZmqSocketOption PgmMaxTransportServiceDataUnitLength = 1001, Correlate = 58, // in zmq it's 52 and 53, but I guess it does not matter so long as it is unique. - Relaxed = 59, - - /// - /// The set of allowed client public keys for a CURVE server socket. - /// When non-empty, only clients whose long-term public key is present in this set will be allowed to connect. - /// - CurveAllowedClients = 61 + Relaxed = 59 } } \ No newline at end of file diff --git a/src/NetMQ/SocketOptions.cs b/src/NetMQ/SocketOptions.cs index f26310ae..2dea116b 100644 --- a/src/NetMQ/SocketOptions.cs +++ b/src/NetMQ/SocketOptions.cs @@ -486,7 +486,7 @@ public NetMQCertificate CurveServerCertificate /// /// public HashSet CurveAllowedClients => - m_socket.GetSocketOptionX>(ZmqSocketOption.CurveAllowedClients)!; + m_socket.SocketHandle.CurveAllowedClients; /// /// If remote peer receives a PING message and doesn't receive another diff --git a/src/NetMQ/ThreadSafeSocketOptions.cs b/src/NetMQ/ThreadSafeSocketOptions.cs index dea11ed1..3f061811 100644 --- a/src/NetMQ/ThreadSafeSocketOptions.cs +++ b/src/NetMQ/ThreadSafeSocketOptions.cs @@ -379,7 +379,7 @@ public NetMQCertificate CurveServerCertificate /// /// public HashSet CurveAllowedClients => - GetSocketOptionX>(ZmqSocketOption.CurveAllowedClients)!; + m_socket.CurveAllowedClients; /// /// If remote peer receives a PING message and doesn't receive another