From be2a24bfd960dd3486903f13e777b15639cdf357 Mon Sep 17 00:00:00 2001 From: ahmed Date: Tue, 14 Jul 2026 17:26:51 +0300 Subject: [PATCH] AES --- Libraries/Esiur/Net/Packets/EpAuthPacket.cs | 3 +- .../Esiur/Net/Packets/EpAuthPacketHeader.cs | 3 +- .../Esiur/Net/Sockets/FrameworkWebSocket.cs | 105 ++- Libraries/Esiur/Protocol/EpConnection.cs | 773 ++++++++++++++++-- .../Esiur/Protocol/EpConnectionContext.cs | 11 + Libraries/Esiur/Protocol/EpServer.cs | 11 + .../Attributes/AuditingManagerAttribute.cs | 13 + .../Attributes/PermissionsManagerAttribute.cs | 13 + .../Attributes/RateControlManagerAttribute.cs | 13 + Libraries/Esiur/Resource/Warehouse.cs | 53 ++ .../Esiur/Resource/WarehouseConfiguration.cs | 10 + Libraries/Esiur/Security/Authority/Session.cs | 13 +- .../Cryptography/AesEncryptionProvider.cs | 482 +++++++++++ .../Cryptography/EncryptionContext.cs | 55 ++ .../Cryptography/IEncryptionProvider.cs | 29 + .../Security/Management/IAuditingManager.cs | 14 + .../Management/IRateControlManager.cs | 13 + .../Security/Management/IResourceManager.cs | 9 + .../Management/ResourceManagerContext.cs | 66 ++ .../Permissions/IPermissionsManager.cs | 3 +- Tests/Features/Functional/Program.cs | 8 + Tests/Features/Functional/README.md | 21 +- Tests/Unit/AesEncryptionProviderTests.cs | 262 ++++++ Tests/Unit/EncryptedRecordLimitTests.cs | 56 ++ Tests/Unit/EncryptionProviderRegistryTests.cs | 22 + Tests/Unit/EpAuthPacketTests.cs | 29 + Tests/Unit/IndexedStructureTests.cs | 9 + .../Unit/Integration/EncryptedEchoResource.cs | 10 + Tests/Unit/Integration/IntegrationHarness.cs | 112 ++- .../SessionHeadersIntegrationTests.cs | 169 ++++ 30 files changed, 2285 insertions(+), 105 deletions(-) create mode 100644 Libraries/Esiur/Resource/Attributes/AuditingManagerAttribute.cs create mode 100644 Libraries/Esiur/Resource/Attributes/PermissionsManagerAttribute.cs create mode 100644 Libraries/Esiur/Resource/Attributes/RateControlManagerAttribute.cs create mode 100644 Libraries/Esiur/Security/Cryptography/AesEncryptionProvider.cs create mode 100644 Libraries/Esiur/Security/Cryptography/EncryptionContext.cs create mode 100644 Libraries/Esiur/Security/Cryptography/IEncryptionProvider.cs create mode 100644 Libraries/Esiur/Security/Management/IAuditingManager.cs create mode 100644 Libraries/Esiur/Security/Management/IRateControlManager.cs create mode 100644 Libraries/Esiur/Security/Management/IResourceManager.cs create mode 100644 Libraries/Esiur/Security/Management/ResourceManagerContext.cs create mode 100644 Tests/Unit/AesEncryptionProviderTests.cs create mode 100644 Tests/Unit/EncryptedRecordLimitTests.cs create mode 100644 Tests/Unit/EncryptionProviderRegistryTests.cs create mode 100644 Tests/Unit/EpAuthPacketTests.cs create mode 100644 Tests/Unit/Integration/EncryptedEchoResource.cs diff --git a/Libraries/Esiur/Net/Packets/EpAuthPacket.cs b/Libraries/Esiur/Net/Packets/EpAuthPacket.cs index 4bc7e11..321b433 100644 --- a/Libraries/Esiur/Net/Packets/EpAuthPacket.cs +++ b/Libraries/Esiur/Net/Packets/EpAuthPacket.cs @@ -158,7 +158,8 @@ public class EpAuthPacket : Packet if (Command == EpAuthPacketCommand.Initialize) { AuthMode = (AuthenticationMode)(data[offset] >> 2 & 0x3); - EncryptionMode = (EncryptionMode)(data[offset++] & 0x7); + // Authentication occupies bits 2-3; encryption is confined to bits 0-1. + EncryptionMode = (EncryptionMode)(data[offset++] & 0x3); } else if (Command == EpAuthPacketCommand.Acknowledge) { diff --git a/Libraries/Esiur/Net/Packets/EpAuthPacketHeader.cs b/Libraries/Esiur/Net/Packets/EpAuthPacketHeader.cs index 98971e6..8cb3d8e 100644 --- a/Libraries/Esiur/Net/Packets/EpAuthPacketHeader.cs +++ b/Libraries/Esiur/Net/Packets/EpAuthPacketHeader.cs @@ -22,6 +22,7 @@ namespace Esiur.Net.Packets Identity, AuthenticationProtocol, AuthenticationData, - ErrorMessage + ErrorMessage, + CipherNonce } } diff --git a/Libraries/Esiur/Net/Sockets/FrameworkWebSocket.cs b/Libraries/Esiur/Net/Sockets/FrameworkWebSocket.cs index a716ea8..89d4eb4 100644 --- a/Libraries/Esiur/Net/Sockets/FrameworkWebSocket.cs +++ b/Libraries/Esiur/Net/Sockets/FrameworkWebSocket.cs @@ -28,6 +28,8 @@ namespace Esiur.Net.Sockets ArraySegment websocketReceiveBufferSegment; object sendLock = new object(); + readonly SemaphoreSlim sendSemaphore = new SemaphoreSlim(1, 1); + int sendFailureNotified; bool held; public event DestroyedEvent OnDestroy; @@ -70,7 +72,7 @@ namespace Esiur.Net.Sockets public void Send(byte[] message) { - + byte[] queued = null; lock (sendLock) { if (held) @@ -79,16 +81,18 @@ namespace Esiur.Net.Sockets } else { - totalSent += message.Length; - sock.SendAsync(new ArraySegment(message), WebSocketMessageType.Binary, - true, new System.Threading.CancellationToken()); + queued = (byte[])message.Clone(); } } + + if (queued != null) + ObserveSend(QueueSend(queued)); } public void Send(byte[] message, int offset, int size) { + byte[] queued = null; lock (sendLock) { if (held) @@ -97,12 +101,13 @@ namespace Esiur.Net.Sockets } else { - totalSent += size; - - sock.SendAsync(new ArraySegment(message, offset, size), - WebSocketMessageType.Binary, true, new System.Threading.CancellationToken()); + queued = new byte[size]; + Buffer.BlockCopy(message, offset, queued, 0, size); } } + + if (queued != null) + ObserveSend(QueueSend(queued)); } @@ -184,41 +189,85 @@ namespace Esiur.Net.Sockets public void Unhold() { + byte[] message; lock (sendLock) { held = false; - - var message = sendNetworkBuffer.Read(); - - if (message == null) - return; - - totalSent += message.Length; - - sock.SendAsync(new ArraySegment(message), WebSocketMessageType.Binary, - true, new System.Threading.CancellationToken()); - + message = sendNetworkBuffer.Read(); } + + if (message != null) + ObserveSend(QueueSend(message)); } public async AsyncReply SendAsync(byte[] message, int offset, int length) { - if (held) + byte[] queued = null; + lock (sendLock) { - sendNetworkBuffer.Write(message, (uint)offset, (uint)length); - } - else - { - totalSent += length; - - await sock.SendAsync(new ArraySegment(message, offset, length), - WebSocketMessageType.Binary, true, new System.Threading.CancellationToken()); + if (held) + { + sendNetworkBuffer.Write(message, (uint)offset, (uint)length); + } + else + { + queued = new byte[length]; + Buffer.BlockCopy(message, offset, queued, 0, length); + } } + if (queued != null) + await QueueSend(queued); return true; } + async Task QueueSend(byte[] message) + { + await sendSemaphore.WaitAsync(); + try + { + var socket = sock ?? throw new InvalidOperationException("WebSocket is closed."); + await socket.SendAsync( + new ArraySegment(message), + WebSocketMessageType.Binary, + true, + CancellationToken.None); + Interlocked.Add(ref totalSent, message.Length); + } + catch + { + NotifySendFailure(); + throw; + } + finally + { + sendSemaphore.Release(); + } + } + + void ObserveSend(Task task) + { + _ = task.ContinueWith( + completed => + { + // Observe the exception; QueueSend already closed/notified the receiver. + _ = completed.Exception; + }, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + + void NotifySendFailure() + { + if (Interlocked.Exchange(ref sendFailureNotified, 1) == 0) + { + try { sock?.Abort(); } catch { } + Receiver?.NetworkClose(this); + } + } + public ISocket Accept() { throw new NotImplementedException(); diff --git a/Libraries/Esiur/Protocol/EpConnection.cs b/Libraries/Esiur/Protocol/EpConnection.cs index 70da282..89a6fcb 100644 --- a/Libraries/Esiur/Protocol/EpConnection.cs +++ b/Libraries/Esiur/Protocol/EpConnection.cs @@ -38,8 +38,10 @@ using Esiur.Security.Membership; using Esiur.Security.Permissions; using Microsoft.CodeAnalysis.CSharp.Syntax; using System; +using System.Buffers.Binary; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; @@ -47,6 +49,7 @@ using System.Reflection.PortableExecutable; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; +using System.Threading; using System.Threading.Tasks; using System.Timers; @@ -86,6 +89,20 @@ public partial class EpConnection : NetworkConnection, IStore // Fields bool _invalidCredentials = false; + enum OutboundProtectionState : byte + { + Plaintext, + Encrypted, + Closed, + } + + const int EncryptedRecordHeaderSize = 4; + readonly object _encryptionSendLock = new object(); + NetworkBuffer _decryptedReceiveBuffer = new NetworkBuffer(); + OutboundProtectionState _outboundProtectionState = OutboundProtectionState.Plaintext; + volatile bool _decryptInbound; + string[] _offeredEncryptionProviders = Array.Empty(); + System.Timers.Timer _keepAliveTimer; DateTime? _lastKeepAliveSent; DateTime? _lastKeepAliveReceived; @@ -141,6 +158,11 @@ public partial class EpConnection : NetworkConnection, IStore /// public Session Session => _session; + /// + /// True after authenticated encryption is active in both directions. + /// + public bool IsEncrypted => _session?.EncryptionActive ?? false; + [Export] public virtual EpConnectionStatus Status { get; private set; } @@ -236,8 +258,79 @@ public partial class EpConnection : NetworkConnection, IStore Console.WriteLine("Client: {0}", data.Length); #endif - Global.Counters["Ep Sent Packets"]++; - base.Send(data); + lock (_encryptionSendLock) + { + if (_outboundProtectionState == OutboundProtectionState.Closed) + return; + + Global.Counters["Ep Sent Packets"]++; + + if (_outboundProtectionState == OutboundProtectionState.Encrypted) + base.Send(ComposeEncryptedRecord(data)); + else + base.Send(data); + } + } + + public override void Send(byte[] data, int offset, int length) + { + if (data == null) + throw new ArgumentNullException(nameof(data)); + if (offset < 0 || length < 0 || offset > data.Length - length) + throw new ArgumentOutOfRangeException(nameof(offset)); + + var message = new byte[length]; + Buffer.BlockCopy(data, offset, message, 0, length); + Send(message); + } + + public override AsyncReply SendAsync(byte[] message, int offset, int length) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (offset < 0 || length < 0 || offset > message.Length - length) + throw new ArgumentOutOfRangeException(nameof(offset)); + + lock (_encryptionSendLock) + { + if (_outboundProtectionState == OutboundProtectionState.Closed) + return new AsyncReply(false); + if (_outboundProtectionState == OutboundProtectionState.Plaintext) + return base.SendAsync(message, offset, length); + + var plaintext = new byte[length]; + Buffer.BlockCopy(message, offset, plaintext, 0, length); + var record = ComposeEncryptedRecord(plaintext); + return base.SendAsync(record, 0, record.Length); + } + } + + byte[] ComposeEncryptedRecord(byte[] plaintext) + { + var cipher = _session?.SymetricCipher + ?? throw new InvalidOperationException("Session encryption is active without a cipher."); + var provider = _session.EncryptionProvider + ?? throw new InvalidOperationException("Session encryption is active without a provider."); + var maximumRecordSize = ParsingWarehouse.Configuration.Encryption.MaximumRecordSize; + + if (maximumRecordSize > 0 + && (ulong)plaintext.LongLength + provider.MaximumRecordOverhead > maximumRecordSize) + throw new ParserLimitException( + $"Encrypted record would exceed the {maximumRecordSize}-byte limit."); + + var protectedPayload = cipher.Encrypt(plaintext); + + if (maximumRecordSize > 0 && protectedPayload.Length > maximumRecordSize) + throw new InvalidOperationException( + $"Encryption provider `{provider.DefaultName}` exceeded its declared record overhead."); + if (protectedPayload.Length > int.MaxValue - EncryptedRecordHeaderSize) + throw new ParserLimitException("Encrypted record exceeds the runtime allocation limit."); + + var record = new byte[EncryptedRecordHeaderSize + protectedPayload.Length]; + BinaryPrimitives.WriteUInt32BigEndian(record.AsSpan(0, EncryptedRecordHeaderSize), + (uint)protectedPayload.Length); + Buffer.BlockCopy(protectedPayload, 0, record, EncryptedRecordHeaderSize, protectedPayload.Length); + return record; } /// @@ -276,6 +369,24 @@ public partial class EpConnection : NetworkConnection, IStore if (_authDirection != AuthenticationDirection.Initiator) return; + if (_session.EncryptionMode != EncryptionMode.None) + { + try + { + PrepareEncryptionOffer(); + } + catch (Exception ex) + { + _invalidCredentials = true; + FailPendingOpen(new AsyncException( + ErrorType.Management, + 0, + ex.Message)); + Close(); + return; + } + } + var headers = _session.LocalHeaders.Copy(); if (_session.AuthenticationMode != AuthenticationMode.None) @@ -285,21 +396,309 @@ public partial class EpConnection : NetworkConnection, IStore var initAuthResult = _session.AuthenticationHandler.Process(null); + if (initAuthResult.Ruling == AuthenticationRuling.Failed) + throw new InvalidOperationException("Authentication initialization failed."); + + if (initAuthResult.Ruling == AuthenticationRuling.Succeeded) + { + SetSessionKey(initAuthResult.SessionKey); + _session.LocalIdentity = initAuthResult.LocalIdentity; + _session.RemoteIdentity = initAuthResult.RemoteIdentity; + } + headers.AuthenticationProtocol = _session.AuthenticationHandler.Protocol; headers.AuthenticationData = initAuthResult.AuthenticationData; headers.Domain = _remoteDomain; } - if (_session.EncryptionMode != EncryptionMode.None) - { - //@TODO: get the handler - } - SendAuthHeaders((EpAuthPacketMethod)( (byte)EpAuthPacketMethod.Initialize - | (byte)(_session.AuthenticationMode) << 2 - | (byte)_session.EncryptionMode), headers); + | ((byte)_session.AuthenticationMode & 0x3) << 2 + | ((byte)_session.EncryptionMode & 0x3)), headers); + } + + void PrepareEncryptionOffer() + { + if (_session.AuthenticationMode == AuthenticationMode.None) + throw new InvalidOperationException( + "Session-key encryption requires an authenticated session."); + if (_session.EncryptionMode != EncryptionMode.EncryptWithSessionKey + && _session.EncryptionMode != EncryptionMode.EncryptWithSessionKeyAndAddress) + throw new InvalidOperationException($"Unsupported encryption mode `{_session.EncryptionMode}`."); + + var warehouse = Instance?.Warehouse ?? _serverWarehouse ?? Warehouse.Default; + var configured = _offeredEncryptionProviders ?? Array.Empty(); + if (configured.Length == 0) + configured = warehouse.GetEncryptionProviderNames(); + + var offered = configured + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.Ordinal) + .Where(x => warehouse.TryGetEncryptionProvider(x) != null) + .ToArray(); + + if (offered.Length == 0) + throw new InvalidOperationException( + "Encryption was requested but none of the offered providers are registered."); + + _offeredEncryptionProviders = offered; + _session.LocalHeaders.SupportedCiphers = offered; + _session.LocalHeaders.CipherType = null; + _session.LocalHeaders.CipherNonce = Global.GenerateBytes(32); + } + + bool NegotiateEncryptionAsResponder(SessionHeaders localHeaders) + { + _session.EncryptionMode = _authPacket.EncryptionMode; + + if (_session.EncryptionMode == EncryptionMode.None) + { + if (Server?.RequireEncryption == true) + return RejectEncryption("This server requires an encrypted authenticated session."); + + return true; + } + + if (_session.EncryptionMode != EncryptionMode.EncryptWithSessionKey + && _session.EncryptionMode != EncryptionMode.EncryptWithSessionKeyAndAddress) + return RejectEncryption("The requested encryption mode is not supported."); + if (_authPacket.AuthMode == AuthenticationMode.None) + return RejectEncryption("Session-key encryption requires authentication."); + + var offered = _session.RemoteHeaders.SupportedCiphers ?? Array.Empty(); + var allowed = Server?.AllowedEncryptionProviders ?? Array.Empty(); + var selected = offered.FirstOrDefault(name => + !string.IsNullOrWhiteSpace(name) + && allowed.Contains(name, StringComparer.Ordinal) + && _serverWarehouse.TryGetEncryptionProvider(name) != null); + + if (selected == null) + return RejectEncryption("No mutually supported encryption provider is available."); + if (_session.RemoteHeaders.CipherNonce == null + || _session.RemoteHeaders.CipherNonce.Length < 16 + || _session.RemoteHeaders.CipherNonce.Length > 64) + return RejectEncryption("The initiator did not supply a valid cipher nonce."); + + _session.EncryptionProvider = _serverWarehouse.GetEncryptionProvider(selected); + _session.LocalHeaders.SupportedCiphers = allowed + .Where(name => _serverWarehouse.TryGetEncryptionProvider(name) != null) + .Distinct(StringComparer.Ordinal) + .ToArray(); + _session.LocalHeaders.CipherType = selected; + _session.LocalHeaders.CipherNonce = Global.GenerateBytes(32); + + localHeaders.SupportedCiphers = _session.LocalHeaders.SupportedCiphers; + localHeaders.CipherType = selected; + localHeaders.CipherNonce = _session.LocalHeaders.CipherNonce; + return true; + } + + bool AcceptEncryptionAsInitiator() + { + if (_session.EncryptionMode == EncryptionMode.None) + return _session.RemoteHeaders.CipherType == null + || RejectEncryption("The responder selected encryption that was not requested."); + + var selected = _session.RemoteHeaders.CipherType; + if (string.IsNullOrWhiteSpace(selected) + || !_offeredEncryptionProviders.Contains(selected, StringComparer.Ordinal)) + return RejectEncryption("The responder did not select an offered encryption provider."); + if (_session.RemoteHeaders.CipherNonce == null + || _session.RemoteHeaders.CipherNonce.Length < 16 + || _session.RemoteHeaders.CipherNonce.Length > 64) + return RejectEncryption("The responder did not supply a valid cipher nonce."); + + var provider = Instance?.Warehouse.TryGetEncryptionProvider(selected); + if (provider == null) + return RejectEncryption($"Encryption provider `{selected}` is not registered locally."); + + _session.EncryptionProvider = provider; + return true; + } + + bool RejectEncryption(string message) + { + _invalidCredentials = true; + + try + { + SendAuthMessage(EpAuthPacketMethod.ErrorMustEncrypt, message); + } + catch (Exception ex) + { + Global.Log("EpConnection:EncryptionNegotiation", LogType.Warning, ex.Message); + } + + FailPendingOpen(new AsyncException(ErrorType.Management, 0, message)); + Task.Delay(100).ContinueWith(_ => Close()); + return false; + } + + void PrepareSessionEncryption() + { + if (_session.EncryptionMode == EncryptionMode.None || _session.SymetricCipher != null) + return; + if (_session.Key == null || _session.Key.Length == 0) + throw new InvalidOperationException( + "The authentication provider did not derive a session key for encryption."); + if (_session.EncryptionProvider == null) + throw new InvalidOperationException("No encryption provider was negotiated."); + + var initiator = _authDirection == AuthenticationDirection.Initiator; + var initiatorNonce = initiator + ? _session.LocalHeaders.CipherNonce + : _session.RemoteHeaders.CipherNonce; + var responderNonce = initiator + ? _session.RemoteHeaders.CipherNonce + : _session.LocalHeaders.CipherNonce; + var initiatorAddress = initiator + ? _session.RemoteHeaders.IPAddress + : _session.LocalHeaders.IPAddress; + var responderAddress = initiator + ? _session.LocalHeaders.IPAddress + : _session.RemoteHeaders.IPAddress; + var offeredProtocols = initiator + ? _offeredEncryptionProviders + : _session.RemoteHeaders.SupportedCiphers; + var authenticationProtocol = initiator + ? _session.AuthenticationHandler?.Protocol + : _session.RemoteHeaders.AuthenticationProtocol; + + _session.SymetricCipher = _session.EncryptionProvider.CreateCipher(new EncryptionContext + { + Key = _session.Key, + Direction = _authDirection, + Mode = _session.EncryptionMode, + Protocol = initiator + ? _session.RemoteHeaders.CipherType + : _session.LocalHeaders.CipherType, + OfferedProtocols = offeredProtocols?.ToArray() ?? Array.Empty(), + AuthenticationMode = _session.AuthenticationMode, + AuthenticationProtocol = authenticationProtocol, + Domain = initiator ? _remoteDomain : _session.RemoteHeaders.Domain, + InitiatorNonce = initiatorNonce, + ResponderNonce = responderNonce, + InitiatorAddress = initiatorAddress, + ResponderAddress = responderAddress, + }); + } + + void EnableInboundEncryption() + { + if (_session.SymetricCipher == null) + throw new InvalidOperationException("Cannot enable encryption before creating a cipher."); + + lock (_encryptionSendLock) + { + _decryptInbound = true; + _session.EncryptionActive = + _outboundProtectionState == OutboundProtectionState.Encrypted; + } + } + + void EnableEncryption(Action firstProtectedSend = null) + { + if (_session.SymetricCipher == null) + throw new InvalidOperationException("Cannot enable encryption before creating a cipher."); + + lock (_encryptionSendLock) + { + _decryptInbound = true; + _outboundProtectionState = OutboundProtectionState.Encrypted; + _session.EncryptionActive = true; + firstProtectedSend?.Invoke(); + } + } + + void SendPlaintextAndEnableEncryption(Action finalPlaintextSend, Action firstProtectedSend) + { + if (_session.SymetricCipher == null) + throw new InvalidOperationException("Cannot enable encryption before creating a cipher."); + + lock (_encryptionSendLock) + { + finalPlaintextSend(); + _decryptInbound = true; + _outboundProtectionState = OutboundProtectionState.Encrypted; + _session.EncryptionActive = true; + firstProtectedSend(); + } + } + + void SetSessionKey(byte[] key) + { + // Authentication providers own their result buffers. Keep a private copy so + // disconnect cleanup cannot erase a provider-wide or cached shared secret. + var replacement = key == null ? null : (byte[])key.Clone(); + if (_session.Key != null) + Array.Clear(_session.Key, 0, _session.Key.Length); + _session.Key = replacement; + } + + void CompletePendingOpen() + { + var pending = Interlocked.Exchange(ref _openReply, null); + if (pending == null) + return; + + try + { + pending.Trigger(true); + } + catch (Exception ex) + { + Global.Log(ex); + } + } + + void FailPendingOpen(Exception exception) + { + var pending = Interlocked.Exchange(ref _openReply, null); + if (pending == null) + return; + + try + { + pending.TriggerError(exception); + } + catch (Exception ex) + { + Global.Log(ex); + } + } + + void BeginPlaintextHandshake() + { + lock (_encryptionSendLock) + { + _outboundProtectionState = OutboundProtectionState.Plaintext; + _decryptInbound = false; + _decryptedReceiveBuffer = new NetworkBuffer(); + if (_session != null) + _session.EncryptionActive = false; + } + } + + void DisposeSessionEncryption() + { + lock (_encryptionSendLock) + { + _outboundProtectionState = OutboundProtectionState.Closed; + _decryptInbound = false; + _decryptedReceiveBuffer = new NetworkBuffer(); + + if (_session?.SymetricCipher is IDisposable disposable) + disposable.Dispose(); + + if (_session != null) + { + _session.SymetricCipher = null; + _session.EncryptionProvider = null; + _session.EncryptionActive = false; + SetSessionKey(null); + } + } } /// @@ -454,6 +853,7 @@ public partial class EpConnection : NetworkConnection, IStore { TerminateInvocations(); UnsubscribeAll(); + DisposeSessionEncryption(); this.OnReady = null; this.OnError = null; base.Destroy(); @@ -736,8 +1136,12 @@ public partial class EpConnection : NetworkConnection, IStore } _session.RemoteHeaders = remoteHeaders; + _session.AuthenticationMode = _authPacket.AuthMode; var localHeaders = _session.LocalHeaders.Copy(); + if (!NegotiateEncryptionAsResponder(localHeaders)) + return offset; + if (_authPacket.AuthMode == AuthenticationMode.None) { if (!(Server?.AllowUnauthorizedAccess ?? false)) @@ -807,14 +1211,35 @@ public partial class EpConnection : NetworkConnection, IStore } else if (authResult.Ruling == AuthenticationRuling.Succeeded) { - SendAuthHeaders(EpAuthPacketMethod.SessionEstablished, localHeaders); - _session.Authenticated = true; _session.LocalIdentity = authResult.LocalIdentity; _session.RemoteIdentity = authResult.RemoteIdentity; - _session.Key = authResult.SessionKey; + SetSessionKey(authResult.SessionKey); - AuthenticatonCompleted(); + try + { + PrepareSessionEncryption(); + } + catch (Exception ex) + { + RejectEncryption(ex.Message); + return offset; + } + + AuthenticatonCompleted(() => + { + // The initiator needs the selected provider and responder nonce + // before it can construct its cipher, so this acknowledgement is + // intentionally the final plaintext packet in a one-step handshake. + if (_session.EncryptionMode != EncryptionMode.None) + { + SendPlaintextAndEnableEncryption( + () => SendAuthHeaders(EpAuthPacketMethod.SessionEstablished, localHeaders), + () => SendAuth(EpAuthPacketMethod.Established)); + } + else + SendAuthHeaders(EpAuthPacketMethod.SessionEstablished, localHeaders); + }); } } else if (_authPacket.Command == EpAuthPacketCommand.Acknowledge) @@ -829,11 +1254,69 @@ public partial class EpConnection : NetworkConnection, IStore _session.Authenticated = true; _session.LocalIdentity = null; _session.RemoteIdentity = null; - _session.Key = null; + SetSessionKey(null); AuthenticatonCompleted(); return offset; } + if (_session.AuthenticationMode != AuthenticationMode.None + && _authPacket.Method == EpAuthPacketMethod.SessionEstablished) + { + var remoteHeaders = new SessionHeaders(); + object remoteAuthData = null; + + if (_authPacket.Tdu != null) + { + remoteHeaders = Codec.ParseIndexedType( + _authPacket.Tdu.Value, + Instance.Warehouse); + remoteAuthData = remoteHeaders.AuthenticationData; + remoteHeaders.AuthenticationData = null; + } + + _session.RemoteHeaders = remoteHeaders; + if (!AcceptEncryptionAsInitiator()) + return offset; + + if (_session.Key == null) + { + var authResult = _session.AuthenticationHandler.Process(remoteAuthData); + if (authResult.Ruling != AuthenticationRuling.Succeeded) + { + _invalidCredentials = true; + FailPendingOpen(new AsyncException( + ErrorType.Management, + 0, + "Authentication did not produce a session key.")); + Task.Delay(100).ContinueWith(_ => Close()); + return offset; + } + + SetSessionKey(authResult.SessionKey); + _session.LocalIdentity = authResult.LocalIdentity; + _session.RemoteIdentity = authResult.RemoteIdentity; + } + + _session.Authenticated = true; + + try + { + PrepareSessionEncryption(); + if (_session.EncryptionMode != EncryptionMode.None) + EnableInboundEncryption(); + } + catch (Exception ex) + { + RejectEncryption(ex.Message); + return offset; + } + + if (_session.EncryptionMode == EncryptionMode.None) + AuthenticatonCompleted(); + + return offset; + } + if (_authPacket.Method == EpAuthPacketMethod.ProceedToHandshake || _authPacket.Method == EpAuthPacketMethod.ProceedToFinalHandshake) { @@ -851,6 +1334,9 @@ public partial class EpConnection : NetworkConnection, IStore _session.RemoteHeaders = remoteHeaders; + if (!AcceptEncryptionAsInitiator()) + return offset; + if (_session.AuthenticationMode == AuthenticationMode.None) { if (_authPacket.Method == EpAuthPacketMethod.SessionEstablished) @@ -858,7 +1344,7 @@ public partial class EpConnection : NetworkConnection, IStore _session.Authenticated = true; _session.LocalIdentity = null; _session.RemoteIdentity = null; - _session.Key = null; + SetSessionKey(null); AuthenticatonCompleted(); } else @@ -894,14 +1380,27 @@ public partial class EpConnection : NetworkConnection, IStore else if (authResult.Ruling == AuthenticationRuling.Succeeded) { _session.Authenticated = true; - _session.Key = authResult.SessionKey; + SetSessionKey(authResult.SessionKey); _session.LocalIdentity = authResult.LocalIdentity; _session.RemoteIdentity = authResult.RemoteIdentity; + try + { + PrepareSessionEncryption(); + } + catch (Exception ex) + { + RejectEncryption(ex.Message); + return offset; + } + // send final handshake with data SendAuthData(EpAuthPacketMethod.FinalHandshake, authResult.AuthenticationData); + if (_session.EncryptionMode != EncryptionMode.None) + EnableInboundEncryption(); + //if (_authPacket.Method == EpAuthPacketMethod.SessionEstablished) //{ // AuthenticatonCompleted(authResult.LocalIdentity, authResult.RemoteIdentity); @@ -929,7 +1428,10 @@ public partial class EpConnection : NetworkConnection, IStore _invalidCredentials = true; OnError?.Invoke(this, _authPacket.ErrorCode, errorMessage); - _openReply?.TriggerError(new AsyncException(ErrorType.Management, _authPacket.ErrorCode, "Authentication error.")); + FailPendingOpen(new AsyncException( + ErrorType.Management, + _authPacket.ErrorCode, + errorMessage)); } } @@ -962,21 +1464,53 @@ public partial class EpConnection : NetworkConnection, IStore else if (authResult.Ruling == AuthenticationRuling.Succeeded) { _session.Authenticated = true; - _session.Key = authResult.SessionKey; + SetSessionKey(authResult.SessionKey); _session.LocalIdentity = authResult.LocalIdentity; _session.RemoteIdentity = authResult.RemoteIdentity; - if (authResult.AuthenticationData != null) - { - SendAuthData(EpAuthPacketMethod.FinalHandshake, authResult.AuthenticationData); - } - if (_authDirection == AuthenticationDirection.Responder && _authPacket.Method == EpAuthPacketMethod.FinalHandshake) { - // Send established event - SendAuth(EpAuthPacketMethod.Established); - AuthenticatonCompleted(); + try + { + PrepareSessionEncryption(); + } + catch (Exception ex) + { + RejectEncryption(ex.Message); + return offset; + } + + // Registration and receive readiness must complete before the + // initiator is allowed to send its first application request. + AuthenticatonCompleted(() => + { + if (_session.EncryptionMode != EncryptionMode.None) + { + EnableEncryption(() => + { + if (authResult.AuthenticationData != null) + SendAuthData(EpAuthPacketMethod.FinalHandshake, + authResult.AuthenticationData); + + // The completion packet is protected and confirms that + // both peers derived the same transcript-bound key. + SendAuth(EpAuthPacketMethod.Established); + }); + } + else + { + if (authResult.AuthenticationData != null) + SendAuthData(EpAuthPacketMethod.FinalHandshake, + authResult.AuthenticationData); + SendAuth(EpAuthPacketMethod.Established); + } + }); + } + else if (authResult.AuthenticationData != null) + { + SendAuthData(EpAuthPacketMethod.FinalHandshake, + authResult.AuthenticationData); } } @@ -998,7 +1532,10 @@ public partial class EpConnection : NetworkConnection, IStore _invalidCredentials = true; OnError?.Invoke(this, _authPacket.ErrorCode, errorMessage); - _openReply?.TriggerError(new AsyncException(ErrorType.Management, _authPacket.ErrorCode, "Authentication error.")); + FailPendingOpen(new AsyncException( + ErrorType.Management, + _authPacket.ErrorCode, + errorMessage)); Task.Delay(100).ContinueWith(x => Close()); } @@ -1006,13 +1543,16 @@ public partial class EpConnection : NetworkConnection, IStore { if (_session.Authenticated) { + if (_session.EncryptionMode != EncryptionMode.None) + EnableEncryption(); + AuthenticatonCompleted(); } else { _invalidCredentials = true; OnError?.Invoke(this, _authPacket.ErrorCode, "Authentication error."); - _openReply?.TriggerError(new AsyncException(ErrorType.Management, _authPacket.ErrorCode, "Authentication error.")); + FailPendingOpen(new AsyncException(ErrorType.Management, _authPacket.ErrorCode, "Authentication error.")); Task.Delay(100).ContinueWith(x => Close()); } } @@ -1031,7 +1571,7 @@ public partial class EpConnection : NetworkConnection, IStore - void AuthenticatonCompleted() + void AuthenticatonCompleted(Action beforeReady = null) { if (this.Instance == null) @@ -1043,9 +1583,9 @@ public partial class EpConnection : NetworkConnection, IStore _authenticated = true; + beforeReady?.Invoke(); Status = EpConnectionStatus.Connected; - _openReply?.Trigger(true); - _openReply = null; + CompletePendingOpen(); OnReady?.Invoke(this); _session.AuthenticationHandler?.Provider?.Login(_session); @@ -1054,13 +1594,13 @@ public partial class EpConnection : NetworkConnection, IStore }).Error(x => { - _openReply?.TriggerError(x); - _openReply = null; + FailPendingOpen(x); }); } else { _authenticated = true; + beforeReady?.Invoke(); Status = EpConnectionStatus.Connected; _session.AuthenticationHandler?.Provider?.Login(_session); @@ -1095,25 +1635,22 @@ public partial class EpConnection : NetworkConnection, IStore bag.Then((o) => { - _openReply?.Trigger(true); - _openReply = null; + CompletePendingOpen(); }); }).Error(ex => { - _openReply.TriggerError(ex); + FailPendingOpen(ex); // do nothing, proxies won't work but connection is established }); } else { - _openReply?.Trigger(true); - _openReply = null; + CompletePendingOpen(); } } else { - _openReply?.Trigger(true); - _openReply = null; + CompletePendingOpen(); } } @@ -1814,30 +2351,60 @@ public partial class EpConnection : NetworkConnection, IStore protected override void DataReceived(NetworkBuffer data) { var msg = data.Read(); - uint offset = 0; - uint ends = (uint)msg.Length; - - var packs = new List(); - - var chunkId = (new Random()).Next(1000, 1000000); + if (msg == null) + return; this.Socket.Hold(); try { - while (offset < ends) - { - offset = processPacket(msg, offset, ends, data, chunkId); - } + if (_decryptInbound) + ProcessEncryptedRecords(msg, data); + else + ProcessPlainPackets(msg, data); } catch (ParserLimitException ex) { + _invalidCredentials = true; Global.Log("EpConnection:ParserLimit", LogType.Warning, ex.Message); + FailPendingOpen(new AsyncException( + ErrorType.Management, + 0, + "Session establishment exceeded a configured parser limit.")); + Close(); + } + catch (CryptographicException ex) + { + _invalidCredentials = true; + Global.Log("EpConnection:Encryption", LogType.Warning, ex.Message); + FailPendingOpen(new AsyncException( + ErrorType.Management, + 0, + "Encrypted session validation failed.")); + Close(); + } + catch (InvalidDataException ex) + { + _invalidCredentials = true; + Global.Log("EpConnection:Encryption", LogType.Warning, ex.Message); + FailPendingOpen(new AsyncException( + ErrorType.Management, + 0, + "Encrypted session framing is invalid.")); Close(); } catch (Exception ex) { Global.Log(ex); + if (_decryptInbound) + { + _invalidCredentials = true; + FailPendingOpen(new AsyncException( + ErrorType.Management, + 0, + "Encrypted session processing failed.")); + Close(); + } } finally { @@ -1845,6 +2412,85 @@ public partial class EpConnection : NetworkConnection, IStore } } + void ProcessPlainPackets(byte[] msg, NetworkBuffer holdingBuffer) + { + uint offset = 0; + var ends = (uint)msg.Length; + var chunkId = (new Random()).Next(1000, 1000000); + + while (offset < ends) + { + var encryptionWasEnabled = _decryptInbound; + offset = processPacket(msg, offset, ends, holdingBuffer, chunkId); + + // A handshake packet may switch the remainder of the same socket read to + // encrypted records. Preserve that boundary even when TCP coalesces writes. + if (!encryptionWasEnabled && _decryptInbound && offset < ends) + { + var remaining = new byte[ends - offset]; + Buffer.BlockCopy(msg, (int)offset, remaining, 0, remaining.Length); + ProcessEncryptedRecords(remaining, holdingBuffer); + return; + } + } + } + + void ProcessEncryptedRecords(byte[] data, NetworkBuffer holdingBuffer) + { + uint offset = 0; + var ends = (uint)data.Length; + + while (offset < ends) + { + var remaining = ends - offset; + if (remaining < EncryptedRecordHeaderSize) + { + holdingBuffer.HoldFor(data, offset, remaining, EncryptedRecordHeaderSize); + return; + } + + var protectedLength = BinaryPrimitives.ReadUInt32BigEndian( + data.AsSpan((int)offset, EncryptedRecordHeaderSize)); + var maximumRecordSize = ParsingWarehouse.Configuration.Encryption.MaximumRecordSize; + + if (maximumRecordSize > 0 && protectedLength > maximumRecordSize) + throw new ParserLimitException( + $"Encrypted record of {protectedLength} bytes exceeds the {maximumRecordSize}-byte limit."); + if (protectedLength > int.MaxValue) + throw new ParserLimitException("Encrypted record exceeds the runtime allocation limit."); + if (protectedLength > uint.MaxValue - EncryptedRecordHeaderSize) + throw new InvalidDataException("Encrypted record length is invalid."); + + var totalLength = protectedLength + EncryptedRecordHeaderSize; + if (remaining < totalLength) + { + holdingBuffer.HoldFor(data, offset, remaining, totalLength); + return; + } + + var protectedPayload = new byte[(int)protectedLength]; + Buffer.BlockCopy(data, + (int)offset + EncryptedRecordHeaderSize, + protectedPayload, + 0, + protectedPayload.Length); + + var cipher = _session?.SymetricCipher + ?? throw new InvalidDataException("Encrypted data arrived before cipher initialization."); + var plaintext = cipher.Decrypt(protectedPayload); + _decryptedReceiveBuffer.Write(plaintext); + + while (_decryptedReceiveBuffer.Available > 0 && !_decryptedReceiveBuffer.Protected) + { + var plainPacket = _decryptedReceiveBuffer.Read(); + if (plainPacket != null) + ProcessPlainPackets(plainPacket, _decryptedReceiveBuffer); + } + + offset += totalLength; + } + } + /// /// Resource interface /// @@ -1901,6 +2547,8 @@ public partial class EpConnection : NetworkConnection, IStore } _session.AuthenticationMode = epContext.AuthenticationMode; + _session.EncryptionMode = epContext.EncryptionMode; + _offeredEncryptionProviders = epContext.EncryptionProviders ?? Array.Empty(); _session.LocalIdentity = epContext.Identity; ReconnectInterval = epContext.ReconnectInterval; ExceptionLevel = epContext.ExceptionLevel; @@ -1928,18 +2576,20 @@ public partial class EpConnection : NetworkConnection, IStore public AsyncReply Connect(ISocket socket = null, string hostname = null, ushort port = 0, string domain = null) { - if (_openReply != null) + if (IsConnected || Status == EpConnectionStatus.Connected) + throw new AsyncException(ErrorType.Exception, 0, "Connection is already established"); + var openReply = new AsyncReply(); + if (Interlocked.CompareExchange(ref _openReply, openReply, null) != null) throw new AsyncException(ErrorType.Exception, 0, "Connection in progress"); Status = EpConnectionStatus.Connecting; - _openReply = new AsyncReply(); - // set auth direction to initiator _authDirection = AuthenticationDirection.Initiator; if (hostname != null) { + DisposeSessionEncryption(); _session = new Session(); _authDirection = AuthenticationDirection.Initiator; _invalidCredentials = false; @@ -1954,6 +2604,8 @@ public partial class EpConnection : NetworkConnection, IStore if (_session == null) throw new AsyncException(ErrorType.Exception, 0, "Session not initialized"); + BeginPlaintextHandshake(); + if (socket == null) { var os = RuntimeInformation.FrameworkDescription; @@ -1966,7 +2618,7 @@ public partial class EpConnection : NetworkConnection, IStore connectSocket(socket); - return _openReply; + return openReply; } void connectSocket(ISocket socket) @@ -1983,8 +2635,7 @@ public partial class EpConnection : NetworkConnection, IStore } else { - _openReply.TriggerError(x); - _openReply = null; + FailPendingOpen(x); } }); @@ -2107,9 +2758,17 @@ public partial class EpConnection : NetworkConnection, IStore protected override void Disconnected() { // clean up + var wasAuthenticated = _authenticated || (_session?.Authenticated ?? false); TerminateInvocations(); + DisposeSessionEncryption(); _authenticated = false; + if (_session != null) + _session.Authenticated = false; Status = EpConnectionStatus.Closed; + FailPendingOpen(new AsyncException( + ErrorType.Management, + 0, + "Connection closed before session establishment completed.")); _keepAliveTimer.Stop(); @@ -2176,7 +2835,7 @@ public partial class EpConnection : NetworkConnection, IStore UnsubscribeAll(); Instance?.Warehouse?.Remove(this); - if (_authenticated) + if (wasAuthenticated) { _session.AuthenticationHandler?.Provider.Logout(_session); //Server.Membership?.Logout(_session); diff --git a/Libraries/Esiur/Protocol/EpConnectionContext.cs b/Libraries/Esiur/Protocol/EpConnectionContext.cs index c4c4d5c..7f427f4 100644 --- a/Libraries/Esiur/Protocol/EpConnectionContext.cs +++ b/Libraries/Esiur/Protocol/EpConnectionContext.cs @@ -3,6 +3,7 @@ using Esiur.Data; using Esiur.Net.Packets; using Esiur.Resource; using Esiur.Security.Authority; +using Esiur.Security.Cryptography; using Esiur.Security.Membership; using Esiur.Security.Permissions; using System; @@ -40,6 +41,16 @@ public class EpConnectionContext : IResourceContext public string Identity { get; set; } public string AuthenticationProtocol { get; set; } = "hash"; + /// + /// Controls whether the authenticated session key must protect EP traffic. + /// + public EncryptionMode EncryptionMode { get; set; } = EncryptionMode.None; + + /// + /// Encryption provider protocol names offered to the responder, in preference order. + /// + public string[] EncryptionProviders { get; set; } = new[] { "aes-gcm" }; + public bool AutoReconnect { get; set; } = false; public uint ReconnectInterval { get; set; } = 5; diff --git a/Libraries/Esiur/Protocol/EpServer.cs b/Libraries/Esiur/Protocol/EpServer.cs index f1cd87c..7da1791 100644 --- a/Libraries/Esiur/Protocol/EpServer.cs +++ b/Libraries/Esiur/Protocol/EpServer.cs @@ -59,6 +59,17 @@ public class EpServer : NetworkServer, IResource //[Attribute] public string[] AllowedAuthenticationProviders { get; set; } + /// + /// Encryption provider protocol names that incoming connections may negotiate. + /// Providers must also be registered with the server Warehouse. + /// + public string[] AllowedEncryptionProviders { get; set; } = Array.Empty(); + + /// + /// Rejects incoming sessions that do not request authenticated encryption. + /// + public bool RequireEncryption { get; set; } + //[Attribute] public bool AllowUnauthorizedAccess { get; set; } diff --git a/Libraries/Esiur/Resource/Attributes/AuditingManagerAttribute.cs b/Libraries/Esiur/Resource/Attributes/AuditingManagerAttribute.cs new file mode 100644 index 0000000..eb4784b --- /dev/null +++ b/Libraries/Esiur/Resource/Attributes/AuditingManagerAttribute.cs @@ -0,0 +1,13 @@ +using Esiur.Security.Management; +using System; + +namespace Esiur.Resource; + +/// +/// Associates a registered auditing-manager implementation with a resource type. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class AuditingManagerAttribute : Attribute + where T : IAuditingManager +{ +} diff --git a/Libraries/Esiur/Resource/Attributes/PermissionsManagerAttribute.cs b/Libraries/Esiur/Resource/Attributes/PermissionsManagerAttribute.cs new file mode 100644 index 0000000..58135a6 --- /dev/null +++ b/Libraries/Esiur/Resource/Attributes/PermissionsManagerAttribute.cs @@ -0,0 +1,13 @@ +using Esiur.Security.Permissions; +using System; + +namespace Esiur.Resource; + +/// +/// Associates a registered permissions-manager implementation with a resource type. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class PermissionsManagerAttribute : Attribute + where T : IPermissionsManager +{ +} diff --git a/Libraries/Esiur/Resource/Attributes/RateControlManagerAttribute.cs b/Libraries/Esiur/Resource/Attributes/RateControlManagerAttribute.cs new file mode 100644 index 0000000..79bb495 --- /dev/null +++ b/Libraries/Esiur/Resource/Attributes/RateControlManagerAttribute.cs @@ -0,0 +1,13 @@ +using Esiur.Security.Management; +using System; + +namespace Esiur.Resource; + +/// +/// Associates a registered rate-control-manager implementation with a resource type. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class RateControlManagerAttribute : Attribute + where T : IRateControlManager +{ +} diff --git a/Libraries/Esiur/Resource/Warehouse.cs b/Libraries/Esiur/Resource/Warehouse.cs index 67fc885..3472cc7 100644 --- a/Libraries/Esiur/Resource/Warehouse.cs +++ b/Libraries/Esiur/Resource/Warehouse.cs @@ -30,6 +30,7 @@ using Esiur.Net.Packets; using Esiur.Protocol; using Esiur.Proxy; using Esiur.Security.Authority; +using Esiur.Security.Cryptography; using Esiur.Security.Permissions; using Esiur.Security.RateLimiting; using Org.BouncyCastle.Asn1.Cms; @@ -88,6 +89,8 @@ public class Warehouse Map _authenticationProviders = new Map(); + readonly ConcurrentDictionary _encryptionProviders + = new ConcurrentDictionary(StringComparer.Ordinal); List _permissionsManagers = new List(); readonly ConcurrentDictionary _ratePolicies = new ConcurrentDictionary(StringComparer.Ordinal); @@ -123,6 +126,30 @@ public class Warehouse _authenticationProviders.Add(name, provider); } + /// + /// Registers an encryption provider using its default protocol name. + /// + public void RegisterEncryptionProvider(IEncryptionProvider provider) + { + if (provider == null) + throw new ArgumentNullException(nameof(provider)); + + RegisterEncryptionProvider(provider.DefaultName, provider); + } + + /// + /// Registers an encryption provider under a protocol name used during session negotiation. + /// + public void RegisterEncryptionProvider(string name, IEncryptionProvider provider) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("An encryption provider name is required.", nameof(name)); + if (provider == null) + throw new ArgumentNullException(nameof(provider)); + if (!_encryptionProviders.TryAdd(name, provider)) + throw new InvalidOperationException($"An encryption provider named `{name}` is already registered."); + } + public void RegisterPermissionsManager(IPermissionsManager manager) { @@ -179,6 +206,32 @@ public class Warehouse return null; } + /// + /// Gets a registered encryption provider or throws when the protocol is unavailable. + /// + public IEncryptionProvider GetEncryptionProvider(string name) + { + if (TryGetEncryptionProvider(name) is { } provider) + return provider; + + throw new InvalidOperationException($"Encryption provider `{name}` was not found."); + } + + /// + /// Attempts to get a registered encryption provider by protocol name. + /// + public IEncryptionProvider? TryGetEncryptionProvider(string name) + => !string.IsNullOrWhiteSpace(name) + && _encryptionProviders.TryGetValue(name, out var provider) + ? provider + : null; + + /// + /// Returns a snapshot of encryption protocol names registered in this Warehouse. + /// + public string[] GetEncryptionProviderNames() + => _encryptionProviders.Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray(); + public Warehouse() : this(new WarehouseConfiguration()) { diff --git a/Libraries/Esiur/Resource/WarehouseConfiguration.cs b/Libraries/Esiur/Resource/WarehouseConfiguration.cs index 3c35313..f041510 100644 --- a/Libraries/Esiur/Resource/WarehouseConfiguration.cs +++ b/Libraries/Esiur/Resource/WarehouseConfiguration.cs @@ -11,6 +11,16 @@ public sealed class WarehouseConfiguration public ParserConfiguration Parser { get; set; } = new ParserConfiguration(); public ResourceAttachmentConfiguration ResourceAttachments { get; set; } = new ResourceAttachmentConfiguration(); public ConnectionConfiguration Connections { get; set; } = new ConnectionConfiguration(); + public EncryptionConfiguration Encryption { get; set; } = new EncryptionConfiguration(); +} + +/// +/// Bounds encrypted transport records before any peer-controlled allocation occurs. +/// A value of zero disables the limit. +/// +public sealed class EncryptionConfiguration +{ + public uint MaximumRecordSize { get; set; } = 8 * 1024 * 1024 + 1024; } /// diff --git a/Libraries/Esiur/Security/Authority/Session.cs b/Libraries/Esiur/Security/Authority/Session.cs index 3a28da3..405e1fb 100644 --- a/Libraries/Esiur/Security/Authority/Session.cs +++ b/Libraries/Esiur/Security/Authority/Session.cs @@ -55,7 +55,7 @@ public sealed class SessionHeaders : IndexedStructure public object? SupportedHashAlgorithms { get; set; } [Index((int)EpAuthPacketHeader.SupportedCiphers)] - public object? SupportedCiphers { get; set; } + public string[]? SupportedCiphers { get; set; } [Index((int)EpAuthPacketHeader.SupportedCompression)] public object? SupportedCompression { get; set; } @@ -64,7 +64,7 @@ public sealed class SessionHeaders : IndexedStructure public object? SupportedMultiFactorAuthentications { get; set; } [Index((int)EpAuthPacketHeader.CipherType)] - public object? CipherType { get; set; } + public string? CipherType { get; set; } [Index((int)EpAuthPacketHeader.CipherKey)] public byte[]? CipherKey { get; set; } @@ -93,6 +93,13 @@ public sealed class SessionHeaders : IndexedStructure [Index((int)EpAuthPacketHeader.ErrorMessage)] public string? ErrorMessage { get; set; } + /// + /// Fresh public nonce used with the authenticated session key to derive unique + /// per-connection encryption keys. This value is not a secret key. + /// + [Index((int)EpAuthPacketHeader.CipherNonce)] + public byte[]? CipherNonce { get; set; } + internal SessionHeaders Copy() => (SessionHeaders)MemberwiseClone(); } @@ -107,6 +114,8 @@ public class Session //public IKeyExchanger KeyExchanger { get; set; } = null; public ISymetricCipher SymetricCipher { get; set; } = null; + public IEncryptionProvider EncryptionProvider { get; set; } = null; + public bool EncryptionActive { get; internal set; } public SessionHeaders LocalHeaders { get; set; } = new SessionHeaders(); diff --git a/Libraries/Esiur/Security/Cryptography/AesEncryptionProvider.cs b/Libraries/Esiur/Security/Cryptography/AesEncryptionProvider.cs new file mode 100644 index 0000000..b54acba --- /dev/null +++ b/Libraries/Esiur/Security/Cryptography/AesEncryptionProvider.cs @@ -0,0 +1,482 @@ +using Esiur.Security.Authority; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Modes; +using Org.BouncyCastle.Crypto.Parameters; +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace Esiur.Security.Cryptography; + +/// +/// Creates AES-256-GCM record ciphers. Session keys and nonce prefixes are +/// derived with HKDF-SHA256 and separated by protocol direction and purpose. +/// +public sealed class AesEncryptionProvider : IEncryptionProvider +{ + public const string Name = "aes-gcm"; + + public string DefaultName => Name; + + public uint MaximumRecordOverhead => 8 + 16; + + public ISymetricCipher CreateCipher(EncryptionContext context) + => new AesGcmSymetricCipher(context); +} + +/// +/// AES-256-GCM session record cipher. +/// +/// A record contains an eight-byte, big-endian sequence followed by ciphertext +/// and a sixteen-byte GCM tag. The transport's four-byte record length and the +/// sequence are authenticated as associated data. Sequence numbers are implicit +/// state as well as explicit record fields, so replay and reordering fail closed. +/// +public sealed class AesGcmSymetricCipher : ISymetricCipher, IDisposable +{ + const int KeySize = 32; + const int NoncePrefixSize = 4; + const int SequenceSize = 8; + const int TagSize = 16; + const int RecordOverhead = SequenceSize + TagSize; + const int AadSize = 4 + SequenceSize; + const int MinimumSharedKeySize = 16; + const int MaximumSharedKeySize = 1024; + const int MinimumPeerNonceSize = 16; + const int MaximumPeerNonceSize = 64; + + static readonly byte[] ContextLabel = Encoding.ASCII.GetBytes("esiur/ep/aes-256-gcm/context/v3"); + static readonly byte[] InitiatorToResponderKeyLabel = Encoding.ASCII.GetBytes("esiur/ep/aes-256-gcm/v1/initiator-to-responder/key"); + static readonly byte[] ResponderToInitiatorKeyLabel = Encoding.ASCII.GetBytes("esiur/ep/aes-256-gcm/v1/responder-to-initiator/key"); + static readonly byte[] InitiatorToResponderNonceLabel = Encoding.ASCII.GetBytes("esiur/ep/aes-256-gcm/v1/initiator-to-responder/nonce"); + static readonly byte[] ResponderToInitiatorNonceLabel = Encoding.ASCII.GetBytes("esiur/ep/aes-256-gcm/v1/responder-to-initiator/nonce"); + + readonly object _sendLock = new object(); + readonly object _receiveLock = new object(); + readonly byte[] _contextSalt; + readonly byte[] _sendKeyLabel; + readonly byte[] _receiveKeyLabel; + readonly byte[] _sendNonceLabel; + readonly byte[] _receiveNonceLabel; + + byte[] _sendKey; + byte[] _receiveKey; + byte[] _sendNoncePrefix; + byte[] _receiveNoncePrefix; + ulong _sendSequence; + ulong _receiveSequence; + bool _keyInitialized; + bool _disposed; + + internal AesGcmSymetricCipher(EncryptionContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + ValidateContext(context); + + _contextSalt = ComposeContextSalt(context); + + if (context.Direction == AuthenticationDirection.Initiator) + { + _sendKeyLabel = InitiatorToResponderKeyLabel; + _receiveKeyLabel = ResponderToInitiatorKeyLabel; + _sendNonceLabel = InitiatorToResponderNonceLabel; + _receiveNonceLabel = ResponderToInitiatorNonceLabel; + } + else + { + _sendKeyLabel = ResponderToInitiatorKeyLabel; + _receiveKeyLabel = InitiatorToResponderKeyLabel; + _sendNonceLabel = ResponderToInitiatorNonceLabel; + _receiveNonceLabel = InitiatorToResponderNonceLabel; + } + + var acceptedKey = SetKey(context.Key); + Clear(acceptedKey); + } + + /// Identifier retained for compatibility with . + public ushort Identifier => (ushort)SymetricEncryptionAlgorithmType.AES; + + public byte[] Encrypt(byte[] data) + { + if (data == null) + throw new ArgumentNullException(nameof(data)); + + if (data.Length > int.MaxValue - RecordOverhead) + throw new ArgumentOutOfRangeException(nameof(data), "The encrypted record exceeds the 32-bit record length."); + + lock (_sendLock) + { + ThrowIfDisposed(); + + if (_sendSequence == ulong.MaxValue) + throw new CryptographicException("The AES-GCM send sequence is exhausted."); + + var sequence = _sendSequence; + var recordLength = data.Length + RecordOverhead; + var nonce = ComposeNonce(_sendNoncePrefix, sequence); + var aad = ComposeAad((uint)recordLength, sequence); + var cipherText = new byte[data.Length + TagSize]; + + try + { + var cipher = new GcmBlockCipher(AesUtilities.CreateEngine()); + cipher.Init(true, new AeadParameters(new KeyParameter(_sendKey), TagSize * 8, nonce, aad)); + + var written = cipher.ProcessBytes(data, 0, data.Length, cipherText, 0); + written += cipher.DoFinal(cipherText, written); + + if (written != cipherText.Length) + throw new CryptographicException("AES-GCM produced an invalid record length."); + + var record = new byte[recordLength]; + WriteUInt64BigEndian(record, 0, sequence); + Buffer.BlockCopy(cipherText, 0, record, SequenceSize, cipherText.Length); + + _sendSequence++; + return record; + } + catch (CryptoException ex) + { + throw new CryptographicException("AES-GCM record encryption failed.", ex); + } + finally + { + Clear(nonce); + Clear(aad); + Clear(cipherText); + } + } + } + + public byte[] Decrypt(byte[] data) + { + if (data == null) + throw new ArgumentNullException(nameof(data)); + if (data.Length < RecordOverhead) + throw new CryptographicException("The AES-GCM record is truncated."); + + lock (_receiveLock) + { + ThrowIfDisposed(); + + var sequence = ReadUInt64BigEndian(data, 0); + if (sequence != _receiveSequence) + throw new CryptographicException("The AES-GCM record sequence is invalid."); + + if (_receiveSequence == ulong.MaxValue) + throw new CryptographicException("The AES-GCM receive sequence is exhausted."); + + var recordLength = checked((uint)data.Length); + var cipherTextLength = data.Length - SequenceSize; + var plainText = new byte[cipherTextLength - TagSize]; + var nonce = ComposeNonce(_receiveNoncePrefix, sequence); + var aad = ComposeAad(recordLength, sequence); + + try + { + var cipher = new GcmBlockCipher(AesUtilities.CreateEngine()); + cipher.Init(false, new AeadParameters(new KeyParameter(_receiveKey), TagSize * 8, nonce, aad)); + + var written = cipher.ProcessBytes(data, SequenceSize, cipherTextLength, plainText, 0); + written += cipher.DoFinal(plainText, written); + + if (written != plainText.Length) + throw new CryptographicException("AES-GCM produced an invalid plaintext length."); + + _receiveSequence++; + return plainText; + } + catch (CryptoException ex) + { + Clear(plainText); + throw new CryptographicException("AES-GCM record authentication failed.", ex); + } + catch + { + Clear(plainText); + throw; + } + finally + { + Clear(nonce); + Clear(aad); + } + } + } + + /// + /// Initializes the cipher key. Session ciphers are deliberately immutable after + /// construction because resetting a key would also reset the GCM nonce sequence. + /// Create a new cipher with fresh peer nonces to use different key material. + /// + public byte[] SetKey(byte[] key) + { + ValidateSharedKey(key); + + lock (_sendLock) + { + lock (_receiveLock) + { + ThrowIfDisposed(); + + if (_keyInitialized) + throw new InvalidOperationException( + "AES-GCM session keys are immutable; create a new cipher with fresh peer nonces."); + + byte[] sendKey = null; + byte[] receiveKey = null; + byte[] sendNoncePrefix = null; + byte[] receiveNoncePrefix = null; + + try + { + sendKey = Derive(key, _contextSalt, _sendKeyLabel, KeySize); + receiveKey = Derive(key, _contextSalt, _receiveKeyLabel, KeySize); + sendNoncePrefix = Derive(key, _contextSalt, _sendNonceLabel, NoncePrefixSize); + receiveNoncePrefix = Derive(key, _contextSalt, _receiveNonceLabel, NoncePrefixSize); + + Clear(_sendKey); + Clear(_receiveKey); + Clear(_sendNoncePrefix); + Clear(_receiveNoncePrefix); + + _sendKey = sendKey; + _receiveKey = receiveKey; + _sendNoncePrefix = sendNoncePrefix; + _receiveNoncePrefix = receiveNoncePrefix; + sendKey = null; + receiveKey = null; + sendNoncePrefix = null; + receiveNoncePrefix = null; + _sendSequence = 0; + _receiveSequence = 0; + _keyInitialized = true; + + return (byte[])key.Clone(); + } + finally + { + Clear(sendKey); + Clear(receiveKey); + Clear(sendNoncePrefix); + Clear(receiveNoncePrefix); + } + } + } + } + + public void Dispose() + { + lock (_sendLock) + { + lock (_receiveLock) + { + if (_disposed) + return; + + _disposed = true; + Clear(_sendKey); + Clear(_receiveKey); + Clear(_sendNoncePrefix); + Clear(_receiveNoncePrefix); + Clear(_contextSalt); + _sendKey = null; + _receiveKey = null; + _sendNoncePrefix = null; + _receiveNoncePrefix = null; + _sendSequence = 0; + _receiveSequence = 0; + } + } + + GC.SuppressFinalize(this); + } + + static void ValidateContext(EncryptionContext context) + { + ValidateSharedKey(context.Key); + ValidateNonce(context.InitiatorNonce, nameof(context.InitiatorNonce)); + ValidateNonce(context.ResponderNonce, nameof(context.ResponderNonce)); + + if (context.Direction != AuthenticationDirection.Initiator && + context.Direction != AuthenticationDirection.Responder) + throw new ArgumentOutOfRangeException(nameof(context.Direction)); + + if (context.Mode == EncryptionMode.None) + throw new ArgumentException("AES-GCM requires an encrypted session mode.", nameof(context)); + if (context.Mode != EncryptionMode.EncryptWithSessionKey && + context.Mode != EncryptionMode.EncryptWithSessionKeyAndAddress) + throw new ArgumentOutOfRangeException(nameof(context.Mode)); + if (string.IsNullOrWhiteSpace(context.Protocol)) + throw new ArgumentException("A negotiated encryption protocol is required.", nameof(context)); + if (context.OfferedProtocols == null || context.OfferedProtocols.Length == 0) + throw new ArgumentException("The initiator's encryption offer is required.", nameof(context)); + if (context.OfferedProtocols.Any(string.IsNullOrWhiteSpace)) + throw new ArgumentException("Encryption offers cannot contain empty protocol names.", nameof(context)); + if (!context.OfferedProtocols.Contains(context.Protocol, StringComparer.Ordinal)) + throw new ArgumentException("The selected encryption protocol was not offered.", nameof(context)); + if (context.AuthenticationMode == AuthenticationMode.None) + throw new ArgumentException("AES-GCM requires an authenticated session.", nameof(context)); + if (string.IsNullOrWhiteSpace(context.AuthenticationProtocol)) + throw new ArgumentException("A negotiated authentication protocol is required.", nameof(context)); + + if (context.Mode == EncryptionMode.EncryptWithSessionKeyAndAddress) + { + ValidateAddress(context.InitiatorAddress, nameof(context.InitiatorAddress)); + ValidateAddress(context.ResponderAddress, nameof(context.ResponderAddress)); + + if (IsUnspecifiedAddress(context.InitiatorAddress) + || IsUnspecifiedAddress(context.ResponderAddress)) + throw new ArgumentException( + "Address-bound encryption requires concrete peer network addresses.", + nameof(context)); + } + } + + static void ValidateSharedKey(byte[] key) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (key.Length < MinimumSharedKeySize || key.Length > MaximumSharedKeySize) + throw new ArgumentException( + $"The shared key must contain between {MinimumSharedKeySize} and {MaximumSharedKeySize} bytes.", + nameof(key)); + } + + static void ValidateNonce(byte[] nonce, string parameterName) + { + if (nonce == null) + throw new ArgumentNullException(parameterName); + if (nonce.Length < MinimumPeerNonceSize || nonce.Length > MaximumPeerNonceSize) + throw new ArgumentException( + $"A cipher nonce must contain between {MinimumPeerNonceSize} and {MaximumPeerNonceSize} bytes.", + parameterName); + } + + static void ValidateAddress(byte[] address, string parameterName) + { + if (address == null) + throw new ArgumentNullException(parameterName); + if (address.Length != 4 && address.Length != 16) + throw new ArgumentException("An address must be an IPv4 or IPv6 byte sequence.", parameterName); + } + + static byte[] ComposeContextSalt(EncryptionContext context) + { + var digest = new Sha256Digest(); + digest.BlockUpdate(ContextLabel, 0, ContextLabel.Length); + digest.Update((byte)context.AuthenticationMode); + DigestLengthPrefixed(digest, Encoding.UTF8.GetBytes(context.AuthenticationProtocol)); + DigestLengthPrefixed(digest, Encoding.UTF8.GetBytes(context.Domain ?? string.Empty)); + digest.Update((byte)context.Mode); + DigestUInt32(digest, checked((uint)context.OfferedProtocols.Length)); + foreach (var offeredProtocol in context.OfferedProtocols) + DigestLengthPrefixed(digest, Encoding.UTF8.GetBytes(offeredProtocol)); + DigestLengthPrefixed(digest, Encoding.UTF8.GetBytes(context.Protocol)); + DigestLengthPrefixed(digest, context.InitiatorNonce); + DigestLengthPrefixed(digest, context.ResponderNonce); + + if (context.Mode == EncryptionMode.EncryptWithSessionKeyAndAddress) + { + DigestLengthPrefixed(digest, context.InitiatorAddress); + DigestLengthPrefixed(digest, context.ResponderAddress); + } + + var salt = new byte[digest.GetDigestSize()]; + digest.DoFinal(salt, 0); + return salt; + } + + static void DigestLengthPrefixed(Sha256Digest digest, byte[] value) + { + var length = new byte[4]; + WriteUInt32BigEndian(length, 0, checked((uint)value.Length)); + digest.BlockUpdate(length, 0, length.Length); + digest.BlockUpdate(value, 0, value.Length); + Clear(length); + } + + static void DigestUInt32(Sha256Digest digest, uint value) + { + var encoded = new byte[4]; + WriteUInt32BigEndian(encoded, 0, value); + digest.BlockUpdate(encoded, 0, encoded.Length); + Clear(encoded); + } + + static bool IsUnspecifiedAddress(byte[] address) + => address.All(value => value == 0); + + static byte[] Derive(byte[] key, byte[] salt, byte[] label, int size) + { + var generator = new HkdfBytesGenerator(new Sha256Digest()); + generator.Init(new HkdfParameters(key, salt, label)); + var result = new byte[size]; + generator.GenerateBytes(result, 0, result.Length); + return result; + } + + static byte[] ComposeNonce(byte[] prefix, ulong sequence) + { + var nonce = new byte[NoncePrefixSize + SequenceSize]; + Buffer.BlockCopy(prefix, 0, nonce, 0, NoncePrefixSize); + WriteUInt64BigEndian(nonce, NoncePrefixSize, sequence); + return nonce; + } + + static byte[] ComposeAad(uint recordLength, ulong sequence) + { + var aad = new byte[AadSize]; + WriteUInt32BigEndian(aad, 0, recordLength); + WriteUInt64BigEndian(aad, 4, sequence); + return aad; + } + + static void WriteUInt32BigEndian(byte[] destination, int offset, uint value) + { + destination[offset] = (byte)(value >> 24); + destination[offset + 1] = (byte)(value >> 16); + destination[offset + 2] = (byte)(value >> 8); + destination[offset + 3] = (byte)value; + } + + static void WriteUInt64BigEndian(byte[] destination, int offset, ulong value) + { + destination[offset] = (byte)(value >> 56); + destination[offset + 1] = (byte)(value >> 48); + destination[offset + 2] = (byte)(value >> 40); + destination[offset + 3] = (byte)(value >> 32); + destination[offset + 4] = (byte)(value >> 24); + destination[offset + 5] = (byte)(value >> 16); + destination[offset + 6] = (byte)(value >> 8); + destination[offset + 7] = (byte)value; + } + + static ulong ReadUInt64BigEndian(byte[] source, int offset) + => ((ulong)source[offset] << 56) + | ((ulong)source[offset + 1] << 48) + | ((ulong)source[offset + 2] << 40) + | ((ulong)source[offset + 3] << 32) + | ((ulong)source[offset + 4] << 24) + | ((ulong)source[offset + 5] << 16) + | ((ulong)source[offset + 6] << 8) + | source[offset + 7]; + + static void Clear(byte[] value) + { + if (value != null) + Array.Clear(value, 0, value.Length); + } + + void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(AesGcmSymetricCipher)); + } +} diff --git a/Libraries/Esiur/Security/Cryptography/EncryptionContext.cs b/Libraries/Esiur/Security/Cryptography/EncryptionContext.cs new file mode 100644 index 0000000..60a8998 --- /dev/null +++ b/Libraries/Esiur/Security/Cryptography/EncryptionContext.cs @@ -0,0 +1,55 @@ +using Esiur.Security.Authority; + +namespace Esiur.Security.Cryptography; + +/// +/// Authenticated and negotiated material used to create a session cipher. +/// Initiator and responder values always retain their protocol roles, regardless +/// of which peer creates the cipher. +/// +public sealed class EncryptionContext +{ + /// Shared secret produced by the authentication provider. + public byte[] Key { get; set; } + + /// Role of the peer creating the cipher. + public AuthenticationDirection Direction { get; set; } + + /// Negotiated encryption mode. + public EncryptionMode Mode { get; set; } + + /// Negotiated provider protocol name. + public string Protocol { get; set; } + + /// + /// Encryption protocols offered by the initiator, in their original wire order. + /// Binding the complete offer prevents an intermediary from silently removing or + /// reordering stronger choices during negotiation. + /// + public string[] OfferedProtocols { get; set; } + + /// Authentication mode that produced the shared session key. + public AuthenticationMode AuthenticationMode { get; set; } + + /// Negotiated authentication protocol that produced the shared key. + public string AuthenticationProtocol { get; set; } + + /// + /// Authentication realm/domain requested by the initiator. This is part of the + /// authenticated encryption transcript so credentials cannot be redirected to a + /// different realm that happens to derive the same shared key. + /// + public string Domain { get; set; } + + /// Fresh public nonce generated by the session initiator. + public byte[] InitiatorNonce { get; set; } + + /// Fresh public nonce generated by the session responder. + public byte[] ResponderNonce { get; set; } + + /// Initiator address used by address-bound encryption mode. + public byte[] InitiatorAddress { get; set; } + + /// Responder address used by address-bound encryption mode. + public byte[] ResponderAddress { get; set; } +} diff --git a/Libraries/Esiur/Security/Cryptography/IEncryptionProvider.cs b/Libraries/Esiur/Security/Cryptography/IEncryptionProvider.cs new file mode 100644 index 0000000..c5e4ec8 --- /dev/null +++ b/Libraries/Esiur/Security/Cryptography/IEncryptionProvider.cs @@ -0,0 +1,29 @@ +namespace Esiur.Security.Cryptography; + +/// +/// Creates a per-session symmetric cipher from authenticated session material. +/// Providers are registered and negotiated by . +/// +public interface IEncryptionProvider +{ + /// + /// Gets the stable protocol name advertised during connection establishment. + /// + string DefaultName { get; } + + /// + /// Maximum number of bytes added by + /// to one plaintext record. The transport uses this before encryption so a rejected + /// oversized send cannot consume a cipher sequence number. + /// + uint MaximumRecordOverhead { get; } + + /// + /// Creates an independent authenticated record cipher for one session. + /// Implementations must derive independent directional keys/nonces while binding + /// every negotiation field in , authenticate each record, + /// reject replay/reordering, and fail closed on authentication errors. A cipher and + /// its nonce sequence must never be reused for another connection. + /// + ISymetricCipher CreateCipher(EncryptionContext context); +} diff --git a/Libraries/Esiur/Security/Management/IAuditingManager.cs b/Libraries/Esiur/Security/Management/IAuditingManager.cs new file mode 100644 index 0000000..e74f742 --- /dev/null +++ b/Libraries/Esiur/Security/Management/IAuditingManager.cs @@ -0,0 +1,14 @@ +using Esiur.Security.Permissions; + +namespace Esiur.Security.Management; + +/// +/// Audits a resource operation before it is executed. A +/// result may veto the operation, while +/// and never grant +/// authorization or override another manager's denial. +/// +public interface IAuditingManager : IResourceManager +{ + Ruling Applicable(ResourceManagerContext context); +} diff --git a/Libraries/Esiur/Security/Management/IRateControlManager.cs b/Libraries/Esiur/Security/Management/IRateControlManager.cs new file mode 100644 index 0000000..67f8fbe --- /dev/null +++ b/Libraries/Esiur/Security/Management/IRateControlManager.cs @@ -0,0 +1,13 @@ +using Esiur.Security.Permissions; + +namespace Esiur.Security.Management; + +/// +/// Evaluates whether a resource operation is admitted by rate-control policy. +/// A manager may assign when allowing +/// an operation that should be queued. +/// +public interface IRateControlManager : IResourceManager +{ + Ruling Applicable(ResourceManagerContext context); +} diff --git a/Libraries/Esiur/Security/Management/IResourceManager.cs b/Libraries/Esiur/Security/Management/IResourceManager.cs new file mode 100644 index 0000000..63a9d92 --- /dev/null +++ b/Libraries/Esiur/Security/Management/IResourceManager.cs @@ -0,0 +1,9 @@ +namespace Esiur.Security.Management; + +/// +/// Identifies a manager that participates in resource operation processing. +/// Category interfaces add the behavior appropriate to each manager type. +/// +public interface IResourceManager +{ +} diff --git a/Libraries/Esiur/Security/Management/ResourceManagerContext.cs b/Libraries/Esiur/Security/Management/ResourceManagerContext.cs new file mode 100644 index 0000000..779c607 --- /dev/null +++ b/Libraries/Esiur/Security/Management/ResourceManagerContext.cs @@ -0,0 +1,66 @@ +using Esiur.Data.Types; +using Esiur.Protocol; +using Esiur.Resource; +using Esiur.Security.Authority; +using Esiur.Security.Permissions; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Esiur.Security.Management; + +/// +/// Immutable metadata describing a resource operation being evaluated by managers. +/// is the sole mutable value and allows rate-control managers to +/// request deferred execution. +/// +public sealed class ResourceManagerContext +{ + readonly IReadOnlyList _memberPolicyAttributes; + + public Warehouse Warehouse { get; } + public EpConnection? Connection { get; } + public Session? Session { get; } + public IResource? Resource { get; } + public MemberDef? Member { get; } + public ActionType Action { get; } + public object? Inquirer { get; } + + /// + /// Gets the local attributes that configure manager policy for the target member. + /// The collection is a defensive, read-only snapshot. + /// + public IReadOnlyList MemberPolicyAttributes => _memberPolicyAttributes; + + /// + /// Gets or sets an optional delay requested by a rate-control manager. + /// + public TimeSpan Delay { get; set; } + + public ResourceManagerContext( + Warehouse warehouse, + EpConnection? connection, + Session? session, + IResource? resource, + MemberDef? member, + ActionType action, + object? inquirer = null, + IEnumerable? memberPolicyAttributes = null) + { + Warehouse = warehouse ?? throw new ArgumentNullException(nameof(warehouse)); + Connection = connection; + Session = session; + Resource = resource; + Member = member; + Action = action; + Inquirer = inquirer; + + var policies = memberPolicyAttributes?.ToArray() ?? Array.Empty(); + if (policies.Any(attribute => attribute == null)) + throw new ArgumentException( + "Member policy attributes cannot contain null values.", + nameof(memberPolicyAttributes)); + + _memberPolicyAttributes = Array.AsReadOnly(policies); + } +} diff --git a/Libraries/Esiur/Security/Permissions/IPermissionsManager.cs b/Libraries/Esiur/Security/Permissions/IPermissionsManager.cs index c45ef80..514cbac 100644 --- a/Libraries/Esiur/Security/Permissions/IPermissionsManager.cs +++ b/Libraries/Esiur/Security/Permissions/IPermissionsManager.cs @@ -33,10 +33,11 @@ using System.Linq; using System.Text; using System.Threading.Tasks; using Esiur.Data.Types; +using Esiur.Security.Management; namespace Esiur.Security.Permissions; -public interface IPermissionsManager +public interface IPermissionsManager : IResourceManager { /// /// Check for permission. diff --git a/Tests/Features/Functional/Program.cs b/Tests/Features/Functional/Program.cs index 266421b..aa9b96a 100644 --- a/Tests/Features/Functional/Program.cs +++ b/Tests/Features/Functional/Program.cs @@ -28,6 +28,7 @@ using Esiur.Data.Types; using Esiur.Protocol; using Esiur.Resource; using Esiur.Security.Authority; +using Esiur.Security.Cryptography; using Esiur.Security.Permissions; using Esiur.Security.RateLimiting; using Esiur.Stores; @@ -57,6 +58,7 @@ internal static class Program var service = await StartServer(serverWarehouse, port); connection = await ConnectClient(clientWarehouse, port); + Require(connection.IsEncrypted, "Authenticated connection did not enable AES encryption."); var remote = await connection.Get("sys/service") as EpResource ?? throw new InvalidOperationException("Remote service was not found."); @@ -78,6 +80,7 @@ internal static class Program static async Task StartServer(Warehouse warehouse, ushort port) { warehouse.RegisterAuthenticationProvider(new ServerAuthenticationProvider()); + warehouse.RegisterEncryptionProvider(new AesEncryptionProvider()); warehouse.Configuration.Parser.MaximumPacketSize = 8 * 1024 * 1024; warehouse.Configuration.Parser.MaximumAllocationSize = 4 * 1024 * 1024; warehouse.Configuration.Parser.MaximumCollectionItems = 65_536; @@ -103,6 +106,8 @@ internal static class Program { Port = port, AllowedAuthenticationProviders = new[] { "hash" }, + AllowedEncryptionProviders = new[] { AesEncryptionProvider.Name }, + RequireEncryption = true, }); var service = await warehouse.Put("sys/service", new MyService()); @@ -145,6 +150,7 @@ internal static class Program static async Task ConnectClient(Warehouse warehouse, ushort port) { warehouse.RegisterAuthenticationProvider(new ClientAuthenticationProvider()); + warehouse.RegisterEncryptionProvider(new AesEncryptionProvider()); warehouse.Configuration.ResourceAttachments.MaximumAttachedResourcesPerConnection = 4_096; warehouse.Configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnection = 128; @@ -155,6 +161,8 @@ internal static class Program Identity = "tester", AuthenticationProtocol = "hash", Domain = "test", + EncryptionMode = EncryptionMode.EncryptWithSessionKey, + EncryptionProviders = new[] { AesEncryptionProvider.Name }, }); } diff --git a/Tests/Features/Functional/README.md b/Tests/Features/Functional/README.md index 99740f5..0ba118f 100644 --- a/Tests/Features/Functional/README.md +++ b/Tests/Features/Functional/README.md @@ -12,7 +12,8 @@ Coverage includes: - rate-limit denial propagation to callers; - pull streams backed by `IAsyncEnumerable`; - `TerminateExecution` through async-enumerator disposal; -- `HaltExecution` and `ResumeExecution` for a cooperative push stream. +- `HaltExecution` and `ResumeExecution` for a cooperative push stream; +- AES-256-GCM provider negotiation and encrypted authenticated-session traffic; - parser allocation and collection budgets, attachment quotas, and per-IP connection limits. Run it from the repository root: @@ -55,4 +56,22 @@ warehouse.Configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnecti warehouse.Configuration.ResourceAttachments.RejectDuplicateAttachments = true; warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 64; +warehouse.Configuration.Encryption.MaximumRecordSize = 8 * 1024 * 1024 + 1024; +``` + +Authenticated encryption is opt-in and fails closed when requested. Register the +provider on both Warehouses, allow it on the server, and request it from the client: + +```csharp +serverWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider()); +server.AllowedEncryptionProviders = new[] { AesEncryptionProvider.Name }; +server.RequireEncryption = true; + +clientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider()); +var context = new EpConnectionContext +{ + AuthenticationMode = AuthenticationMode.InitializerIdentity, + EncryptionMode = EncryptionMode.EncryptWithSessionKey, + EncryptionProviders = new[] { AesEncryptionProvider.Name }, +}; ``` diff --git a/Tests/Unit/AesEncryptionProviderTests.cs b/Tests/Unit/AesEncryptionProviderTests.cs new file mode 100644 index 0000000..2861dbf --- /dev/null +++ b/Tests/Unit/AesEncryptionProviderTests.cs @@ -0,0 +1,262 @@ +using System.Reflection; +using System.Security.Cryptography; +using Esiur.Security.Authority; +using Esiur.Security.Cryptography; + +namespace Esiur.Tests.Unit; + +public class AesEncryptionProviderTests +{ + static readonly byte[] SharedKey = Enumerable.Range(1, 64).Select(x => (byte)x).ToArray(); + static readonly byte[] InitiatorNonce = Enumerable.Range(21, 32).Select(x => (byte)x).ToArray(); + static readonly byte[] ResponderNonce = Enumerable.Range(91, 32).Select(x => (byte)x).ToArray(); + static readonly byte[] InitiatorAddress = { 192, 0, 2, 10 }; + static readonly byte[] ResponderAddress = { 198, 51, 100, 20 }; + + readonly AesEncryptionProvider _provider = new(); + + [Fact] + public void DefaultName_IsNegotiableProviderName() + => Assert.Equal("aes-gcm", _provider.DefaultName); + + [Fact] + public void FirstRecord_MatchesStableWireVector() + { + using var initiator = Create(AuthenticationDirection.Initiator); + var record = initiator.Encrypt("Esiur AES-GCM vector"u8.ToArray()); + + Assert.Equal( + "0000000000000000BF5D9D7DA3D6D5232578F966450E5B96B89172D4F186A5C45299C42D8ABB732A07B1E092", + Convert.ToHexString(record)); + } + + [Fact] + public void Records_RoundTripInBothDirections() + { + using var initiator = Create(AuthenticationDirection.Initiator); + using var responder = Create(AuthenticationDirection.Responder); + + var request = Enumerable.Range(0, 513).Select(x => (byte)x).ToArray(); + var response = Enumerable.Range(0, 97).Select(x => (byte)(255 - x)).ToArray(); + + Assert.Equal(request, responder.Decrypt(initiator.Encrypt(request))); + Assert.Equal(response, initiator.Decrypt(responder.Encrypt(response))); + Assert.Empty(responder.Decrypt(initiator.Encrypt(Array.Empty()))); + } + + [Fact] + public void RepeatedPlaintext_UsesDistinctSequencesAndCiphertext() + { + using var initiator = Create(AuthenticationDirection.Initiator); + var plaintext = new byte[] { 1, 3, 3, 7 }; + + var first = initiator.Encrypt(plaintext); + var second = initiator.Encrypt(plaintext); + + Assert.False(first.SequenceEqual(second)); + Assert.Equal(new byte[8], first.Take(8).ToArray()); + Assert.Equal(new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 }, second.Take(8).ToArray()); + } + + [Fact] + public void DirectionalKeys_RejectReflectedRecords() + { + using var initiator = Create(AuthenticationDirection.Initiator); + var record = initiator.Encrypt(new byte[] { 4, 5, 6 }); + + Assert.Throws(() => initiator.Decrypt(record)); + } + + [Fact] + public void TamperedCiphertextOrTag_IsRejected() + { + using var initiator = Create(AuthenticationDirection.Initiator); + using var responder = Create(AuthenticationDirection.Responder); + var record = initiator.Encrypt(Enumerable.Range(0, 64).Select(x => (byte)x).ToArray()); + record[record.Length - 2] ^= 0x80; + + Assert.Throws(() => responder.Decrypt(record)); + } + + [Fact] + public void ReplayedRecord_IsRejected() + { + using var initiator = Create(AuthenticationDirection.Initiator); + using var responder = Create(AuthenticationDirection.Responder); + var record = initiator.Encrypt(new byte[] { 7, 8, 9 }); + + Assert.Equal(new byte[] { 7, 8, 9 }, responder.Decrypt(record)); + Assert.Throws(() => responder.Decrypt(record)); + } + + [Fact] + public void OutOfOrderRecord_IsRejectedWithoutAdvancingReceiveSequence() + { + using var initiator = Create(AuthenticationDirection.Initiator); + using var responder = Create(AuthenticationDirection.Responder); + var first = initiator.Encrypt(new byte[] { 1 }); + var second = initiator.Encrypt(new byte[] { 2 }); + + Assert.Throws(() => responder.Decrypt(second)); + Assert.Equal(new byte[] { 1 }, responder.Decrypt(first)); + Assert.Equal(new byte[] { 2 }, responder.Decrypt(second)); + } + + [Fact] + public void WrongSharedKey_IsRejected() + { + using var initiator = Create(AuthenticationDirection.Initiator); + var wrongKey = (byte[])SharedKey.Clone(); + wrongKey[0] ^= 0xFF; + using var responder = Create(AuthenticationDirection.Responder, wrongKey); + + Assert.Throws(() => + responder.Decrypt(initiator.Encrypt(new byte[] { 10, 20, 30 }))); + } + + [Fact] + public void NegotiatedProtocolName_IsBoundIntoKeyDerivation() + { + var offer = new[] { AesEncryptionProvider.Name, "aes-gcm-other" }; + using var initiator = Create( + AuthenticationDirection.Initiator, + offeredProtocols: offer); + using var responder = Create( + AuthenticationDirection.Responder, + protocol: "aes-gcm-other", + offeredProtocols: offer); + + Assert.Throws(() => + responder.Decrypt(initiator.Encrypt(new byte[] { 1, 2, 3 }))); + } + + [Fact] + public void OriginalOfferedProtocolList_IsBoundIntoKeyDerivation() + { + using var initiator = Create( + AuthenticationDirection.Initiator, + offeredProtocols: new[] { "aes-gcm", "future-cipher" }); + using var responder = Create( + AuthenticationDirection.Responder, + offeredProtocols: new[] { "aes-gcm" }); + + Assert.Throws(() => + responder.Decrypt(initiator.Encrypt(new byte[] { 1, 2, 3 }))); + } + + [Theory] + [InlineData(AuthenticationMode.DualIdentity, "hash")] + [InlineData(AuthenticationMode.InitializerIdentity, "hash-v2")] + public void AuthenticationNegotiation_IsBoundIntoKeyDerivation( + AuthenticationMode responderMode, + string responderProtocol) + { + using var initiator = Create(AuthenticationDirection.Initiator); + using var responder = Create( + AuthenticationDirection.Responder, + authenticationMode: responderMode, + authenticationProtocol: responderProtocol); + + Assert.Throws(() => + responder.Decrypt(initiator.Encrypt(new byte[] { 1, 2, 3 }))); + } + + [Fact] + public void AuthenticationDomain_IsBoundIntoKeyDerivation() + { + using var initiator = Create( + AuthenticationDirection.Initiator, + domain: "realm-a.example"); + using var responder = Create( + AuthenticationDirection.Responder, + domain: "realm-b.example"); + + Assert.Throws(() => + responder.Decrypt(initiator.Encrypt(new byte[] { 1, 2, 3 }))); + } + + [Fact] + public void SetKey_AfterInitialization_IsRejectedForSameOrDifferentKey() + { + using var cipher = Create(AuthenticationDirection.Initiator); + var differentKey = (byte[])SharedKey.Clone(); + differentKey[0] ^= 0xFF; + + Assert.Throws(() => cipher.SetKey(SharedKey)); + Assert.Throws(() => cipher.SetKey(differentKey)); + } + + [Fact] + public void AddressBoundMode_BindsBothPeerAddresses() + { + using var initiator = Create( + AuthenticationDirection.Initiator, + mode: EncryptionMode.EncryptWithSessionKeyAndAddress); + using var responder = Create( + AuthenticationDirection.Responder, + mode: EncryptionMode.EncryptWithSessionKeyAndAddress); + + Assert.Equal(new byte[] { 42 }, responder.Decrypt(initiator.Encrypt(new byte[] { 42 }))); + + using var changedAddress = Create( + AuthenticationDirection.Responder, + mode: EncryptionMode.EncryptWithSessionKeyAndAddress, + responderAddress: new byte[] { 203, 0, 113, 7 }); + + var nextInitiator = Create( + AuthenticationDirection.Initiator, + mode: EncryptionMode.EncryptWithSessionKeyAndAddress); + using (nextInitiator) + { + Assert.Throws(() => + changedAddress.Decrypt(nextInitiator.Encrypt(new byte[] { 42 }))); + } + } + + [Fact] + public void Dispose_ClearsDerivedSecretsAndRejectsFurtherUse() + { + var cipher = Create(AuthenticationDirection.Initiator); + var secretFields = new[] { "_sendKey", "_receiveKey", "_sendNoncePrefix", "_receiveNoncePrefix" } + .Select(name => typeof(AesGcmSymetricCipher).GetField( + name, + BindingFlags.Instance | BindingFlags.NonPublic)!) + .ToArray(); + var secrets = secretFields.Select(field => (byte[])field.GetValue(cipher)!).ToArray(); + + Assert.All(secrets, secret => Assert.Contains(secret, value => value != 0)); + + cipher.Dispose(); + + Assert.All(secrets, secret => Assert.All(secret, value => Assert.Equal((byte)0, value))); + Assert.All(secretFields, field => Assert.Null(field.GetValue(cipher))); + Assert.Throws(() => cipher.Encrypt(new byte[] { 1 })); + } + + AesGcmSymetricCipher Create( + AuthenticationDirection direction, + byte[]? key = null, + EncryptionMode mode = EncryptionMode.EncryptWithSessionKey, + byte[]? initiatorAddress = null, + byte[]? responderAddress = null, + string protocol = AesEncryptionProvider.Name, + string[]? offeredProtocols = null, + AuthenticationMode authenticationMode = AuthenticationMode.InitializerIdentity, + string authenticationProtocol = "hash", + string domain = "example.test") + => (AesGcmSymetricCipher)_provider.CreateCipher(new EncryptionContext + { + Key = key ?? SharedKey, + Direction = direction, + Mode = mode, + Protocol = protocol, + OfferedProtocols = offeredProtocols ?? new[] { AesEncryptionProvider.Name }, + AuthenticationMode = authenticationMode, + AuthenticationProtocol = authenticationProtocol, + Domain = domain, + InitiatorNonce = InitiatorNonce, + ResponderNonce = ResponderNonce, + InitiatorAddress = initiatorAddress ?? InitiatorAddress, + ResponderAddress = responderAddress ?? ResponderAddress, + }); +} diff --git a/Tests/Unit/EncryptedRecordLimitTests.cs b/Tests/Unit/EncryptedRecordLimitTests.cs new file mode 100644 index 0000000..5a7ff0e --- /dev/null +++ b/Tests/Unit/EncryptedRecordLimitTests.cs @@ -0,0 +1,56 @@ +using System.Reflection; +using Esiur.Data; +using Esiur.Protocol; +using Esiur.Resource; +using Esiur.Security.Cryptography; + +namespace Esiur.Tests.Unit; + +public class EncryptedRecordLimitTests +{ + [Fact] + public void OversizedPlaintext_IsRejectedBeforeCipherSequenceIsConsumed() + { + var warehouse = new Warehouse(); + warehouse.Configuration.Encryption.MaximumRecordSize = 32; + var provider = new CountingEncryptionProvider(); + var connection = new EpConnection(); + connection.Session.EncryptionProvider = provider; + connection.Session.SymetricCipher = provider.Cipher; + + typeof(EpConnection) + .GetField("_serverWarehouse", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(connection, warehouse); + var compose = typeof(EpConnection) + .GetMethod("ComposeEncryptedRecord", BindingFlags.Instance | BindingFlags.NonPublic)!; + + var error = Assert.Throws(() => + compose.Invoke(connection, new object[] { new byte[9] })); + + Assert.IsType(error.InnerException); + Assert.Equal(0, provider.Cipher.EncryptCalls); + } + + sealed class CountingEncryptionProvider : IEncryptionProvider + { + public string DefaultName => "counting"; + public uint MaximumRecordOverhead => 24; + public CountingCipher Cipher { get; } = new CountingCipher(); + public ISymetricCipher CreateCipher(EncryptionContext context) => Cipher; + } + + sealed class CountingCipher : ISymetricCipher + { + public int EncryptCalls { get; private set; } + public ushort Identifier => 0; + + public byte[] Encrypt(byte[] data) + { + EncryptCalls++; + return new byte[data.Length + 24]; + } + + public byte[] Decrypt(byte[] data) => data; + public byte[] SetKey(byte[] key) => key; + } +} diff --git a/Tests/Unit/EncryptionProviderRegistryTests.cs b/Tests/Unit/EncryptionProviderRegistryTests.cs new file mode 100644 index 0000000..c23951d --- /dev/null +++ b/Tests/Unit/EncryptionProviderRegistryTests.cs @@ -0,0 +1,22 @@ +using Esiur.Resource; +using Esiur.Security.Cryptography; + +namespace Esiur.Tests.Unit; + +public class EncryptionProviderRegistryTests +{ + [Fact] + public void Warehouse_RegistersAndResolvesEncryptionProvidersByName() + { + var warehouse = new Warehouse(); + var provider = new AesEncryptionProvider(); + + warehouse.RegisterEncryptionProvider(provider); + + Assert.Same(provider, warehouse.GetEncryptionProvider(AesEncryptionProvider.Name)); + Assert.Same(provider, warehouse.TryGetEncryptionProvider(AesEncryptionProvider.Name)); + Assert.Contains(AesEncryptionProvider.Name, warehouse.GetEncryptionProviderNames()); + Assert.Throws(() => + warehouse.RegisterEncryptionProvider(new AesEncryptionProvider())); + } +} diff --git a/Tests/Unit/EpAuthPacketTests.cs b/Tests/Unit/EpAuthPacketTests.cs new file mode 100644 index 0000000..c362c70 --- /dev/null +++ b/Tests/Unit/EpAuthPacketTests.cs @@ -0,0 +1,29 @@ +using Esiur.Net.Packets; +using Esiur.Resource; +using Esiur.Security.Authority; +using Esiur.Security.Cryptography; + +namespace Esiur.Tests.Unit; + +public class EpAuthPacketTests +{ + [Theory] + [InlineData(AuthenticationMode.None, EncryptionMode.None)] + [InlineData(AuthenticationMode.InitializerIdentity, EncryptionMode.None)] + [InlineData(AuthenticationMode.InitializerIdentity, EncryptionMode.EncryptWithSessionKey)] + [InlineData(AuthenticationMode.ResponderIdentity, EncryptionMode.EncryptWithSessionKeyAndAddress)] + [InlineData(AuthenticationMode.DualIdentity, EncryptionMode.EncryptWithSessionKey)] + public void InitializeHeader_KeepsAuthenticationAndEncryptionBitsIndependent( + AuthenticationMode authenticationMode, + EncryptionMode encryptionMode) + { + var encoded = (byte)((((byte)authenticationMode & 0x3) << 2) + | ((byte)encryptionMode & 0x3)); + var packet = new EpAuthPacket(new Warehouse()); + + Assert.Equal(1, packet.Parse(new[] { encoded }, 0, 1)); + Assert.Equal(EpAuthPacketCommand.Initialize, packet.Command); + Assert.Equal(authenticationMode, packet.AuthMode); + Assert.Equal(encryptionMode, packet.EncryptionMode); + } +} diff --git a/Tests/Unit/IndexedStructureTests.cs b/Tests/Unit/IndexedStructureTests.cs index f0c9b24..e449e3c 100644 --- a/Tests/Unit/IndexedStructureTests.cs +++ b/Tests/Unit/IndexedStructureTests.cs @@ -90,9 +90,12 @@ public class IndexedStructureTests { Version = (byte)3, Domain = "example.test", + SupportedCiphers = new[] { "aes-gcm" }, + CipherType = "aes-gcm", IPAddress = new byte[] { 127, 0, 0, 1 }, AuthenticationProtocol = "hash", AuthenticationData = new byte[] { 1, 2, 3 }, + CipherNonce = Enumerable.Range(0, 32).Select(x => (byte)x).ToArray(), }; var bytes = Codec.ComposeIndexedType(source, Warehouse.Default, null); @@ -100,9 +103,12 @@ public class IndexedStructureTests { [(byte)EpAuthPacketHeader.Version] = source.Version, [(byte)EpAuthPacketHeader.Domain] = source.Domain, + [(byte)EpAuthPacketHeader.SupportedCiphers] = source.SupportedCiphers, + [(byte)EpAuthPacketHeader.CipherType] = source.CipherType, [(byte)EpAuthPacketHeader.IPAddress] = source.IPAddress, [(byte)EpAuthPacketHeader.AuthenticationProtocol] = source.AuthenticationProtocol, [(byte)EpAuthPacketHeader.AuthenticationData] = source.AuthenticationData, + [(byte)EpAuthPacketHeader.CipherNonce] = source.CipherNonce, }, Warehouse.Default, null); var (_, raw) = Codec.ParseSync(bytes, 0, Warehouse.Default); var map = Assert.IsType>(raw); @@ -116,6 +122,9 @@ public class IndexedStructureTests Assert.Equal(source.IPAddress, parsed.IPAddress); Assert.Equal(source.AuthenticationProtocol, parsed.AuthenticationProtocol); Assert.Equal(source.AuthenticationData, parsed.AuthenticationData); + Assert.Equal(source.SupportedCiphers, parsed.SupportedCiphers); + Assert.Equal(source.CipherType, parsed.CipherType); + Assert.Equal(source.CipherNonce, parsed.CipherNonce); } private sealed class InvalidStructure : IndexedStructure diff --git a/Tests/Unit/Integration/EncryptedEchoResource.cs b/Tests/Unit/Integration/EncryptedEchoResource.cs new file mode 100644 index 0000000..c0341b8 --- /dev/null +++ b/Tests/Unit/Integration/EncryptedEchoResource.cs @@ -0,0 +1,10 @@ +using Esiur.Resource; + +namespace Esiur.Tests.Unit.Integration; + +[Resource] +public partial class EncryptedEchoResource +{ + [Export] + public int Echo(int value) => value; +} diff --git a/Tests/Unit/Integration/IntegrationHarness.cs b/Tests/Unit/Integration/IntegrationHarness.cs index ec5f788..1d01e1d 100644 --- a/Tests/Unit/Integration/IntegrationHarness.cs +++ b/Tests/Unit/Integration/IntegrationHarness.cs @@ -6,6 +6,7 @@ using Esiur.Protocol; using Esiur.Resource; using Esiur.Security.Authority; using Esiur.Security.Authority.Providers; +using Esiur.Security.Cryptography; using Esiur.Stores; namespace Esiur.Tests.Unit.Integration; @@ -34,6 +35,50 @@ internal class TestClientAuthProvider : PasswordAuthenticationProvider : new IdentityPassword { Identity = null, Password = null }; } +internal sealed class OneStepAuthenticationProvider : IAuthenticationProvider +{ + readonly byte _keyMarker; + + public OneStepAuthenticationProvider(byte keyMarker = 0) + => _keyMarker = keyMarker; + + public string DefaultName => "one-step"; + + public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context) + => new OneStepAuthenticationHandler(this, _keyMarker); + + public AsyncReply Login(Session session) => new AsyncReply(true); + public AsyncReply Logout(Session session) => new AsyncReply(true); +} + +internal sealed class OneStepAuthenticationHandler : IAuthenticationHandler +{ + static readonly byte[] SharedKey = Enumerable.Range(1, 64).Select(x => (byte)x).ToArray(); + readonly OneStepAuthenticationProvider _provider; + readonly byte _keyMarker; + + public OneStepAuthenticationHandler(OneStepAuthenticationProvider provider, byte keyMarker) + { + _provider = provider; + _keyMarker = keyMarker; + } + + public IAuthenticationProvider Provider => _provider; + public string Protocol => _provider.DefaultName; + + public AuthenticationResult Process(object authData) + { + var key = (byte[])SharedKey.Clone(); + key[0] ^= _keyMarker; + return new AuthenticationResult( + AuthenticationRuling.Succeeded, + null, + "tester", + "server", + key); + } +} + /// /// Spins up an in-process Esiur server and an authenticated client connection over loopback TCP, /// so the real socket + protocol + FetchResource stack is exercised end to end. Each instance @@ -61,18 +106,37 @@ internal sealed class IntegrationCluster : IAsyncDisposable /// Builds a server hosting resources under "sys/<rootPath>" populated by /// , opens it, then connects an authenticated client. /// - public static async Task StartAsync(Func populate) + public static async Task StartAsync( + Func populate, + bool encrypted = false, + bool requireEncryption = false, + EncryptionMode encryptionMode = EncryptionMode.EncryptWithSessionKey, + bool allowEncryption = true, + bool oneStepAuthentication = false, + bool useWebSocket = false, + bool mismatchedSessionKeys = false) { var port = Interlocked.Increment(ref _portCounter); var serverWh = new Warehouse(); - serverWh.RegisterAuthenticationProvider(new TestServerAuthProvider()); + serverWh.RegisterAuthenticationProvider(oneStepAuthentication + ? new OneStepAuthenticationProvider() + : new TestServerAuthProvider()); + if (encrypted || requireEncryption) + serverWh.RegisterEncryptionProvider(new AesEncryptionProvider()); await serverWh.Put("sys", new MemoryStore()); var server = await serverWh.Put("sys/server", new EpServer { Port = (ushort)port, - AllowedAuthenticationProviders = new[] { "hash" }, + AllowedAuthenticationProviders = new[] + { + oneStepAuthentication ? "one-step" : "hash", + }, + AllowedEncryptionProviders = (encrypted || requireEncryption) && allowEncryption + ? new[] { AesEncryptionProvider.Name } + : Array.Empty(), + RequireEncryption = requireEncryption, }); await populate(serverWh); @@ -81,18 +145,38 @@ internal sealed class IntegrationCluster : IAsyncDisposable var cluster = new IntegrationCluster(serverWh, server, port); - cluster.ClientWarehouse.RegisterAuthenticationProvider(new TestClientAuthProvider()); - cluster.Connection = await cluster.ClientWarehouse.Get( - $"ep://localhost:{port}", - new EpConnectionContext - { - AuthenticationMode = AuthenticationMode.InitializerIdentity, - Identity = "tester", - AuthenticationProtocol = "hash", - Domain = "test", - }); + cluster.ClientWarehouse.RegisterAuthenticationProvider(oneStepAuthentication + ? new OneStepAuthenticationProvider(mismatchedSessionKeys ? (byte)0x80 : (byte)0) + : new TestClientAuthProvider()); + if (encrypted) + cluster.ClientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider()); - return cluster; + try + { + cluster.Connection = await cluster.ClientWarehouse.Get( + $"ep://localhost:{port}", + new EpConnectionContext + { + AuthenticationMode = AuthenticationMode.InitializerIdentity, + Identity = "tester", + AuthenticationProtocol = oneStepAuthentication ? "one-step" : "hash", + Domain = "test", + EncryptionMode = encrypted + ? encryptionMode + : EncryptionMode.None, + EncryptionProviders = new[] { AesEncryptionProvider.Name }, + UseWebSocket = useWebSocket, + }); + + return cluster; + } + catch + { + try { server.Destroy(); } catch { } + try { await cluster.ClientWarehouse.Close(); } catch { } + try { await serverWh.Close(); } catch { } + throw; + } } public async ValueTask DisposeAsync() diff --git a/Tests/Unit/Integration/SessionHeadersIntegrationTests.cs b/Tests/Unit/Integration/SessionHeadersIntegrationTests.cs index 4213e60..834818d 100644 --- a/Tests/Unit/Integration/SessionHeadersIntegrationTests.cs +++ b/Tests/Unit/Integration/SessionHeadersIntegrationTests.cs @@ -1,5 +1,10 @@ namespace Esiur.Tests.Unit.Integration; +using Esiur.Data; +using Esiur.Net.Sockets; +using Esiur.Protocol; +using Esiur.Security.Cryptography; + [Collection("Integration")] public class SessionHeadersIntegrationTests { @@ -19,4 +24,168 @@ public class SessionHeadersIntegrationTests Assert.Equal("hash", serverConnection.Session.RemoteHeaders.AuthenticationProtocol); Assert.Null(serverConnection.Session.RemoteHeaders.AuthenticationData); } + + [Fact] + public async Task AuthenticatedHandshake_NegotiatesAesAndEncryptsApplicationTraffic() + { + await using var cluster = await IntegrationCluster + .StartAsync( + async warehouse => + { + await warehouse.Put("sys/encrypted", new Node { Id = 42 }); + }, + encrypted: true) + .WaitAsync(TimeSpan.FromSeconds(10)); + + var serverConnection = Assert.Single(cluster.Server.Connections); + + Assert.True(cluster.Connection.IsEncrypted); + Assert.True(serverConnection.IsEncrypted); + Assert.Equal(EncryptionMode.EncryptWithSessionKey, + cluster.Connection.Session.EncryptionMode); + Assert.Equal(AesEncryptionProvider.Name, + cluster.Connection.Session.RemoteHeaders.CipherType); + Assert.Equal(AesEncryptionProvider.Name, + serverConnection.Session.LocalHeaders.CipherType); + Assert.NotNull(cluster.Connection.Session.SymetricCipher); + Assert.NotNull(serverConnection.Session.SymetricCipher); + Assert.Null(cluster.Connection.Session.LocalHeaders.CipherKey); + Assert.Null(cluster.Connection.Session.RemoteHeaders.CipherKey); + + // Fetch crosses the protected record layer in both directions. + Assert.NotNull(await Task.Run(async () => + await cluster.Connection.Get("sys/encrypted")) + .WaitAsync(TimeSpan.FromSeconds(10))); + } + + [Fact] + public async Task ServerRequiredEncryption_RejectsPlaintextWithoutDowngrade() + { + await Assert.ThrowsAnyAsync(async () => + await IntegrationCluster + .StartAsync( + _ => Task.CompletedTask, + encrypted: false, + requireEncryption: true) + .WaitAsync(TimeSpan.FromSeconds(10))); + } + + [Fact] + public async Task AddressBoundEncryption_DerivesMatchingCiphersAcrossPeers() + { + await using var cluster = await IntegrationCluster + .StartAsync( + async warehouse => + { + await warehouse.Put("sys/address-bound", new Node { Id = 7 }); + }, + encrypted: true, + encryptionMode: EncryptionMode.EncryptWithSessionKeyAndAddress) + .WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.True(cluster.Connection.IsEncrypted); + Assert.Equal(EncryptionMode.EncryptWithSessionKeyAndAddress, + cluster.Connection.Session.EncryptionMode); + Assert.NotNull(await Task.Run(async () => + await cluster.Connection.Get("sys/address-bound")) + .WaitAsync(TimeSpan.FromSeconds(10))); + } + + [Fact] + public async Task DisallowedEncryptionProvider_FailsWithoutPlaintextFallback() + { + await Assert.ThrowsAnyAsync(async () => + await IntegrationCluster + .StartAsync( + _ => Task.CompletedTask, + encrypted: true, + allowEncryption: false) + .WaitAsync(TimeSpan.FromSeconds(10))); + } + + [Fact] + public async Task OneStepAuthentication_ConfirmsEncryptionBeforeFetchAndRpc() + { + await using var cluster = await IntegrationCluster + .StartAsync( + async warehouse => + { + await warehouse.Put("sys/one-step-encrypted", new EncryptedEchoResource()); + }, + encrypted: true, + oneStepAuthentication: true) + .WaitAsync(TimeSpan.FromSeconds(10)); + + var remote = (EpResource)await Task.Run(async () => + await cluster.Connection.Get("sys/one-step-encrypted")) + .WaitAsync(TimeSpan.FromSeconds(10)); + var function = remote.Instance.Definition + .GetFunctionDefByName(nameof(EncryptedEchoResource.Echo)); + var result = await remote._Invoke( + function.Index, + new Map { [0] = 117 }); + + Assert.True(cluster.Connection.Session.Authenticated); + Assert.True(cluster.Connection.IsEncrypted); + var serverConnection = Assert.Single(cluster.Server.Connections); + Assert.True(serverConnection.Session.Authenticated); + Assert.True(serverConnection.IsEncrypted); + Assert.Equal(117, Convert.ToInt32(result)); + } + + [Fact] + public async Task WebSocketTransport_EncryptsFetchAndRpc() + { + await using var cluster = await IntegrationCluster + .StartAsync( + async warehouse => + { + await warehouse.Put("sys/websocket-encrypted", new EncryptedEchoResource()); + }, + encrypted: true, + useWebSocket: true) + .WaitAsync(TimeSpan.FromSeconds(10)); + + var remote = (EpResource)await Task.Run(async () => + await cluster.Connection.Get("sys/websocket-encrypted")) + .WaitAsync(TimeSpan.FromSeconds(10)); + var function = remote.Instance.Definition + .GetFunctionDefByName(nameof(EncryptedEchoResource.Echo)); + var result = await remote._Invoke( + function.Index, + new Map { [0] = 203 }); + + Assert.True(cluster.Connection.IsEncrypted); + Assert.IsType(cluster.Connection.Socket); + Assert.True(Assert.Single(cluster.Server.Connections).IsEncrypted); + Assert.Equal(203, Convert.ToInt32(result)); + } + + [Fact] + public async Task WrongAuthenticatedSessionKey_FailsPromptlyDuringKeyConfirmation() + { + var exception = await Assert.ThrowsAnyAsync(async () => + await IntegrationCluster.StartAsync( + _ => Task.CompletedTask, + encrypted: true, + oneStepAuthentication: true, + mismatchedSessionKeys: true) + .WaitAsync(TimeSpan.FromSeconds(5))); + + Assert.IsNotType(exception); + } + + [Fact] + public async Task AddressBoundEncryption_RejectsWebSocketWithoutConcreteEndpoints() + { + var exception = await Assert.ThrowsAnyAsync(async () => + await IntegrationCluster.StartAsync( + _ => Task.CompletedTask, + encrypted: true, + encryptionMode: EncryptionMode.EncryptWithSessionKeyAndAddress, + useWebSocket: true) + .WaitAsync(TimeSpan.FromSeconds(5))); + + Assert.IsNotType(exception); + } }