This commit is contained in:
2026-07-14 17:26:51 +03:00
parent 13898323dd
commit be2a24bfd9
30 changed files with 2285 additions and 105 deletions
+2 -1
View File
@@ -158,7 +158,8 @@ public class EpAuthPacket : Packet
if (Command == EpAuthPacketCommand.Initialize) if (Command == EpAuthPacketCommand.Initialize)
{ {
AuthMode = (AuthenticationMode)(data[offset] >> 2 & 0x3); 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) else if (Command == EpAuthPacketCommand.Acknowledge)
{ {
@@ -22,6 +22,7 @@ namespace Esiur.Net.Packets
Identity, Identity,
AuthenticationProtocol, AuthenticationProtocol,
AuthenticationData, AuthenticationData,
ErrorMessage ErrorMessage,
CipherNonce
} }
} }
@@ -28,6 +28,8 @@ namespace Esiur.Net.Sockets
ArraySegment<byte> websocketReceiveBufferSegment; ArraySegment<byte> websocketReceiveBufferSegment;
object sendLock = new object(); object sendLock = new object();
readonly SemaphoreSlim sendSemaphore = new SemaphoreSlim(1, 1);
int sendFailureNotified;
bool held; bool held;
public event DestroyedEvent OnDestroy; public event DestroyedEvent OnDestroy;
@@ -70,7 +72,7 @@ namespace Esiur.Net.Sockets
public void Send(byte[] message) public void Send(byte[] message)
{ {
byte[] queued = null;
lock (sendLock) lock (sendLock)
{ {
if (held) if (held)
@@ -79,16 +81,18 @@ namespace Esiur.Net.Sockets
} }
else else
{ {
totalSent += message.Length; queued = (byte[])message.Clone();
sock.SendAsync(new ArraySegment<byte>(message), WebSocketMessageType.Binary,
true, new System.Threading.CancellationToken());
} }
} }
if (queued != null)
ObserveSend(QueueSend(queued));
} }
public void Send(byte[] message, int offset, int size) public void Send(byte[] message, int offset, int size)
{ {
byte[] queued = null;
lock (sendLock) lock (sendLock)
{ {
if (held) if (held)
@@ -97,12 +101,13 @@ namespace Esiur.Net.Sockets
} }
else else
{ {
totalSent += size; queued = new byte[size];
Buffer.BlockCopy(message, offset, queued, 0, size);
sock.SendAsync(new ArraySegment<byte>(message, offset, size),
WebSocketMessageType.Binary, true, new System.Threading.CancellationToken());
} }
} }
if (queued != null)
ObserveSend(QueueSend(queued));
} }
@@ -184,41 +189,85 @@ namespace Esiur.Net.Sockets
public void Unhold() public void Unhold()
{ {
byte[] message;
lock (sendLock) lock (sendLock)
{ {
held = false; held = false;
message = sendNetworkBuffer.Read();
var message = sendNetworkBuffer.Read();
if (message == null)
return;
totalSent += message.Length;
sock.SendAsync(new ArraySegment<byte>(message), WebSocketMessageType.Binary,
true, new System.Threading.CancellationToken());
} }
if (message != null)
ObserveSend(QueueSend(message));
} }
public async AsyncReply<bool> SendAsync(byte[] message, int offset, int length) public async AsyncReply<bool> SendAsync(byte[] message, int offset, int length)
{ {
if (held) byte[] queued = null;
lock (sendLock)
{ {
sendNetworkBuffer.Write(message, (uint)offset, (uint)length); if (held)
} {
else sendNetworkBuffer.Write(message, (uint)offset, (uint)length);
{ }
totalSent += length; else
{
await sock.SendAsync(new ArraySegment<byte>(message, offset, length), queued = new byte[length];
WebSocketMessageType.Binary, true, new System.Threading.CancellationToken()); Buffer.BlockCopy(message, offset, queued, 0, length);
}
} }
if (queued != null)
await QueueSend(queued);
return true; 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<byte>(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() public ISocket Accept()
{ {
throw new NotImplementedException(); throw new NotImplementedException();
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ using Esiur.Data;
using Esiur.Net.Packets; using Esiur.Net.Packets;
using Esiur.Resource; using Esiur.Resource;
using Esiur.Security.Authority; using Esiur.Security.Authority;
using Esiur.Security.Cryptography;
using Esiur.Security.Membership; using Esiur.Security.Membership;
using Esiur.Security.Permissions; using Esiur.Security.Permissions;
using System; using System;
@@ -40,6 +41,16 @@ public class EpConnectionContext : IResourceContext
public string Identity { get; set; } public string Identity { get; set; }
public string AuthenticationProtocol { get; set; } = "hash"; public string AuthenticationProtocol { get; set; } = "hash";
/// <summary>
/// Controls whether the authenticated session key must protect EP traffic.
/// </summary>
public EncryptionMode EncryptionMode { get; set; } = EncryptionMode.None;
/// <summary>
/// Encryption provider protocol names offered to the responder, in preference order.
/// </summary>
public string[] EncryptionProviders { get; set; } = new[] { "aes-gcm" };
public bool AutoReconnect { get; set; } = false; public bool AutoReconnect { get; set; } = false;
public uint ReconnectInterval { get; set; } = 5; public uint ReconnectInterval { get; set; } = 5;
+11
View File
@@ -59,6 +59,17 @@ public class EpServer : NetworkServer<EpConnection>, IResource
//[Attribute] //[Attribute]
public string[] AllowedAuthenticationProviders { get; set; } public string[] AllowedAuthenticationProviders { get; set; }
/// <summary>
/// Encryption provider protocol names that incoming connections may negotiate.
/// Providers must also be registered with the server Warehouse.
/// </summary>
public string[] AllowedEncryptionProviders { get; set; } = Array.Empty<string>();
/// <summary>
/// Rejects incoming sessions that do not request authenticated encryption.
/// </summary>
public bool RequireEncryption { get; set; }
//[Attribute] //[Attribute]
public bool AllowUnauthorizedAccess { get; set; } public bool AllowUnauthorizedAccess { get; set; }
@@ -0,0 +1,13 @@
using Esiur.Security.Management;
using System;
namespace Esiur.Resource;
/// <summary>
/// Associates a registered auditing-manager implementation with a resource type.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class AuditingManagerAttribute<T> : Attribute
where T : IAuditingManager
{
}
@@ -0,0 +1,13 @@
using Esiur.Security.Permissions;
using System;
namespace Esiur.Resource;
/// <summary>
/// Associates a registered permissions-manager implementation with a resource type.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class PermissionsManagerAttribute<T> : Attribute
where T : IPermissionsManager
{
}
@@ -0,0 +1,13 @@
using Esiur.Security.Management;
using System;
namespace Esiur.Resource;
/// <summary>
/// Associates a registered rate-control-manager implementation with a resource type.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class RateControlManagerAttribute<T> : Attribute
where T : IRateControlManager
{
}
+53
View File
@@ -30,6 +30,7 @@ using Esiur.Net.Packets;
using Esiur.Protocol; using Esiur.Protocol;
using Esiur.Proxy; using Esiur.Proxy;
using Esiur.Security.Authority; using Esiur.Security.Authority;
using Esiur.Security.Cryptography;
using Esiur.Security.Permissions; using Esiur.Security.Permissions;
using Esiur.Security.RateLimiting; using Esiur.Security.RateLimiting;
using Org.BouncyCastle.Asn1.Cms; using Org.BouncyCastle.Asn1.Cms;
@@ -88,6 +89,8 @@ public class Warehouse
Map<string, IAuthenticationProvider> _authenticationProviders = new Map<string, IAuthenticationProvider>(); Map<string, IAuthenticationProvider> _authenticationProviders = new Map<string, IAuthenticationProvider>();
readonly ConcurrentDictionary<string, IEncryptionProvider> _encryptionProviders
= new ConcurrentDictionary<string, IEncryptionProvider>(StringComparer.Ordinal);
List<IPermissionsManager> _permissionsManagers = new List<IPermissionsManager>(); List<IPermissionsManager> _permissionsManagers = new List<IPermissionsManager>();
readonly ConcurrentDictionary<string, RatePolicy> _ratePolicies readonly ConcurrentDictionary<string, RatePolicy> _ratePolicies
= new ConcurrentDictionary<string, RatePolicy>(StringComparer.Ordinal); = new ConcurrentDictionary<string, RatePolicy>(StringComparer.Ordinal);
@@ -123,6 +126,30 @@ public class Warehouse
_authenticationProviders.Add(name, provider); _authenticationProviders.Add(name, provider);
} }
/// <summary>
/// Registers an encryption provider using its default protocol name.
/// </summary>
public void RegisterEncryptionProvider(IEncryptionProvider provider)
{
if (provider == null)
throw new ArgumentNullException(nameof(provider));
RegisterEncryptionProvider(provider.DefaultName, provider);
}
/// <summary>
/// Registers an encryption provider under a protocol name used during session negotiation.
/// </summary>
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) public void RegisterPermissionsManager(IPermissionsManager manager)
{ {
@@ -179,6 +206,32 @@ public class Warehouse
return null; return null;
} }
/// <summary>
/// Gets a registered encryption provider or throws when the protocol is unavailable.
/// </summary>
public IEncryptionProvider GetEncryptionProvider(string name)
{
if (TryGetEncryptionProvider(name) is { } provider)
return provider;
throw new InvalidOperationException($"Encryption provider `{name}` was not found.");
}
/// <summary>
/// Attempts to get a registered encryption provider by protocol name.
/// </summary>
public IEncryptionProvider? TryGetEncryptionProvider(string name)
=> !string.IsNullOrWhiteSpace(name)
&& _encryptionProviders.TryGetValue(name, out var provider)
? provider
: null;
/// <summary>
/// Returns a snapshot of encryption protocol names registered in this Warehouse.
/// </summary>
public string[] GetEncryptionProviderNames()
=> _encryptionProviders.Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray();
public Warehouse() : this(new WarehouseConfiguration()) public Warehouse() : this(new WarehouseConfiguration())
{ {
@@ -11,6 +11,16 @@ public sealed class WarehouseConfiguration
public ParserConfiguration Parser { get; set; } = new ParserConfiguration(); public ParserConfiguration Parser { get; set; } = new ParserConfiguration();
public ResourceAttachmentConfiguration ResourceAttachments { get; set; } = new ResourceAttachmentConfiguration(); public ResourceAttachmentConfiguration ResourceAttachments { get; set; } = new ResourceAttachmentConfiguration();
public ConnectionConfiguration Connections { get; set; } = new ConnectionConfiguration(); public ConnectionConfiguration Connections { get; set; } = new ConnectionConfiguration();
public EncryptionConfiguration Encryption { get; set; } = new EncryptionConfiguration();
}
/// <summary>
/// Bounds encrypted transport records before any peer-controlled allocation occurs.
/// A value of zero disables the limit.
/// </summary>
public sealed class EncryptionConfiguration
{
public uint MaximumRecordSize { get; set; } = 8 * 1024 * 1024 + 1024;
} }
/// <summary> /// <summary>
+11 -2
View File
@@ -55,7 +55,7 @@ public sealed class SessionHeaders : IndexedStructure
public object? SupportedHashAlgorithms { get; set; } public object? SupportedHashAlgorithms { get; set; }
[Index((int)EpAuthPacketHeader.SupportedCiphers)] [Index((int)EpAuthPacketHeader.SupportedCiphers)]
public object? SupportedCiphers { get; set; } public string[]? SupportedCiphers { get; set; }
[Index((int)EpAuthPacketHeader.SupportedCompression)] [Index((int)EpAuthPacketHeader.SupportedCompression)]
public object? SupportedCompression { get; set; } public object? SupportedCompression { get; set; }
@@ -64,7 +64,7 @@ public sealed class SessionHeaders : IndexedStructure
public object? SupportedMultiFactorAuthentications { get; set; } public object? SupportedMultiFactorAuthentications { get; set; }
[Index((int)EpAuthPacketHeader.CipherType)] [Index((int)EpAuthPacketHeader.CipherType)]
public object? CipherType { get; set; } public string? CipherType { get; set; }
[Index((int)EpAuthPacketHeader.CipherKey)] [Index((int)EpAuthPacketHeader.CipherKey)]
public byte[]? CipherKey { get; set; } public byte[]? CipherKey { get; set; }
@@ -93,6 +93,13 @@ public sealed class SessionHeaders : IndexedStructure
[Index((int)EpAuthPacketHeader.ErrorMessage)] [Index((int)EpAuthPacketHeader.ErrorMessage)]
public string? ErrorMessage { get; set; } public string? ErrorMessage { get; set; }
/// <summary>
/// Fresh public nonce used with the authenticated session key to derive unique
/// per-connection encryption keys. This value is not a secret key.
/// </summary>
[Index((int)EpAuthPacketHeader.CipherNonce)]
public byte[]? CipherNonce { get; set; }
internal SessionHeaders Copy() => (SessionHeaders)MemberwiseClone(); internal SessionHeaders Copy() => (SessionHeaders)MemberwiseClone();
} }
@@ -107,6 +114,8 @@ public class Session
//public IKeyExchanger KeyExchanger { get; set; } = null; //public IKeyExchanger KeyExchanger { get; set; } = null;
public ISymetricCipher SymetricCipher { 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(); public SessionHeaders LocalHeaders { get; set; } = new SessionHeaders();
@@ -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;
/// <summary>
/// Creates AES-256-GCM record ciphers. Session keys and nonce prefixes are
/// derived with HKDF-SHA256 and separated by protocol direction and purpose.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Identifier retained for compatibility with <see cref="ISymetricCipher"/>.</summary>
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);
}
}
}
/// <summary>
/// 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.
/// </summary>
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));
}
}
@@ -0,0 +1,55 @@
using Esiur.Security.Authority;
namespace Esiur.Security.Cryptography;
/// <summary>
/// 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.
/// </summary>
public sealed class EncryptionContext
{
/// <summary>Shared secret produced by the authentication provider.</summary>
public byte[] Key { get; set; }
/// <summary>Role of the peer creating the cipher.</summary>
public AuthenticationDirection Direction { get; set; }
/// <summary>Negotiated encryption mode.</summary>
public EncryptionMode Mode { get; set; }
/// <summary>Negotiated provider protocol name.</summary>
public string Protocol { get; set; }
/// <summary>
/// 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.
/// </summary>
public string[] OfferedProtocols { get; set; }
/// <summary>Authentication mode that produced the shared session key.</summary>
public AuthenticationMode AuthenticationMode { get; set; }
/// <summary>Negotiated authentication protocol that produced the shared key.</summary>
public string AuthenticationProtocol { get; set; }
/// <summary>
/// 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.
/// </summary>
public string Domain { get; set; }
/// <summary>Fresh public nonce generated by the session initiator.</summary>
public byte[] InitiatorNonce { get; set; }
/// <summary>Fresh public nonce generated by the session responder.</summary>
public byte[] ResponderNonce { get; set; }
/// <summary>Initiator address used by address-bound encryption mode.</summary>
public byte[] InitiatorAddress { get; set; }
/// <summary>Responder address used by address-bound encryption mode.</summary>
public byte[] ResponderAddress { get; set; }
}
@@ -0,0 +1,29 @@
namespace Esiur.Security.Cryptography;
/// <summary>
/// Creates a per-session symmetric cipher from authenticated session material.
/// Providers are registered and negotiated by <see cref="DefaultName"/>.
/// </summary>
public interface IEncryptionProvider
{
/// <summary>
/// Gets the stable protocol name advertised during connection establishment.
/// </summary>
string DefaultName { get; }
/// <summary>
/// Maximum number of bytes added by <see cref="ISymetricCipher.Encrypt(byte[])"/>
/// to one plaintext record. The transport uses this before encryption so a rejected
/// oversized send cannot consume a cipher sequence number.
/// </summary>
uint MaximumRecordOverhead { get; }
/// <summary>
/// Creates an independent authenticated record cipher for one session.
/// Implementations must derive independent directional keys/nonces while binding
/// every negotiation field in <paramref name="context"/>, 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.
/// </summary>
ISymetricCipher CreateCipher(EncryptionContext context);
}
@@ -0,0 +1,14 @@
using Esiur.Security.Permissions;
namespace Esiur.Security.Management;
/// <summary>
/// Audits a resource operation before it is executed. A
/// <see cref="Ruling.Denied"/> result may veto the operation, while
/// <see cref="Ruling.Allowed"/> and <see cref="Ruling.DontCare"/> never grant
/// authorization or override another manager's denial.
/// </summary>
public interface IAuditingManager : IResourceManager
{
Ruling Applicable(ResourceManagerContext context);
}
@@ -0,0 +1,13 @@
using Esiur.Security.Permissions;
namespace Esiur.Security.Management;
/// <summary>
/// Evaluates whether a resource operation is admitted by rate-control policy.
/// A manager may assign <see cref="ResourceManagerContext.Delay"/> when allowing
/// an operation that should be queued.
/// </summary>
public interface IRateControlManager : IResourceManager
{
Ruling Applicable(ResourceManagerContext context);
}
@@ -0,0 +1,9 @@
namespace Esiur.Security.Management;
/// <summary>
/// Identifies a manager that participates in resource operation processing.
/// Category interfaces add the behavior appropriate to each manager type.
/// </summary>
public interface IResourceManager
{
}
@@ -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;
/// <summary>
/// Immutable metadata describing a resource operation being evaluated by managers.
/// <see cref="Delay"/> is the sole mutable value and allows rate-control managers to
/// request deferred execution.
/// </summary>
public sealed class ResourceManagerContext
{
readonly IReadOnlyList<Attribute> _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; }
/// <summary>
/// Gets the local attributes that configure manager policy for the target member.
/// The collection is a defensive, read-only snapshot.
/// </summary>
public IReadOnlyList<Attribute> MemberPolicyAttributes => _memberPolicyAttributes;
/// <summary>
/// Gets or sets an optional delay requested by a rate-control manager.
/// </summary>
public TimeSpan Delay { get; set; }
public ResourceManagerContext(
Warehouse warehouse,
EpConnection? connection,
Session? session,
IResource? resource,
MemberDef? member,
ActionType action,
object? inquirer = null,
IEnumerable<Attribute>? 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<Attribute>();
if (policies.Any(attribute => attribute == null))
throw new ArgumentException(
"Member policy attributes cannot contain null values.",
nameof(memberPolicyAttributes));
_memberPolicyAttributes = Array.AsReadOnly(policies);
}
}
@@ -33,10 +33,11 @@ using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Esiur.Data.Types; using Esiur.Data.Types;
using Esiur.Security.Management;
namespace Esiur.Security.Permissions; namespace Esiur.Security.Permissions;
public interface IPermissionsManager public interface IPermissionsManager : IResourceManager
{ {
/// <summary> /// <summary>
/// Check for permission. /// Check for permission.
+8
View File
@@ -28,6 +28,7 @@ using Esiur.Data.Types;
using Esiur.Protocol; using Esiur.Protocol;
using Esiur.Resource; using Esiur.Resource;
using Esiur.Security.Authority; using Esiur.Security.Authority;
using Esiur.Security.Cryptography;
using Esiur.Security.Permissions; using Esiur.Security.Permissions;
using Esiur.Security.RateLimiting; using Esiur.Security.RateLimiting;
using Esiur.Stores; using Esiur.Stores;
@@ -57,6 +58,7 @@ internal static class Program
var service = await StartServer(serverWarehouse, port); var service = await StartServer(serverWarehouse, port);
connection = await ConnectClient(clientWarehouse, 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 var remote = await connection.Get("sys/service") as EpResource
?? throw new InvalidOperationException("Remote service was not found."); ?? throw new InvalidOperationException("Remote service was not found.");
@@ -78,6 +80,7 @@ internal static class Program
static async Task<MyService> StartServer(Warehouse warehouse, ushort port) static async Task<MyService> StartServer(Warehouse warehouse, ushort port)
{ {
warehouse.RegisterAuthenticationProvider(new ServerAuthenticationProvider()); warehouse.RegisterAuthenticationProvider(new ServerAuthenticationProvider());
warehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
warehouse.Configuration.Parser.MaximumPacketSize = 8 * 1024 * 1024; warehouse.Configuration.Parser.MaximumPacketSize = 8 * 1024 * 1024;
warehouse.Configuration.Parser.MaximumAllocationSize = 4 * 1024 * 1024; warehouse.Configuration.Parser.MaximumAllocationSize = 4 * 1024 * 1024;
warehouse.Configuration.Parser.MaximumCollectionItems = 65_536; warehouse.Configuration.Parser.MaximumCollectionItems = 65_536;
@@ -103,6 +106,8 @@ internal static class Program
{ {
Port = port, Port = port,
AllowedAuthenticationProviders = new[] { "hash" }, AllowedAuthenticationProviders = new[] { "hash" },
AllowedEncryptionProviders = new[] { AesEncryptionProvider.Name },
RequireEncryption = true,
}); });
var service = await warehouse.Put("sys/service", new MyService()); var service = await warehouse.Put("sys/service", new MyService());
@@ -145,6 +150,7 @@ internal static class Program
static async Task<EpConnection> ConnectClient(Warehouse warehouse, ushort port) static async Task<EpConnection> ConnectClient(Warehouse warehouse, ushort port)
{ {
warehouse.RegisterAuthenticationProvider(new ClientAuthenticationProvider()); warehouse.RegisterAuthenticationProvider(new ClientAuthenticationProvider());
warehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
warehouse.Configuration.ResourceAttachments.MaximumAttachedResourcesPerConnection = 4_096; warehouse.Configuration.ResourceAttachments.MaximumAttachedResourcesPerConnection = 4_096;
warehouse.Configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnection = 128; warehouse.Configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnection = 128;
@@ -155,6 +161,8 @@ internal static class Program
Identity = "tester", Identity = "tester",
AuthenticationProtocol = "hash", AuthenticationProtocol = "hash",
Domain = "test", Domain = "test",
EncryptionMode = EncryptionMode.EncryptWithSessionKey,
EncryptionProviders = new[] { AesEncryptionProvider.Name },
}); });
} }
+20 -1
View File
@@ -12,7 +12,8 @@ Coverage includes:
- rate-limit denial propagation to callers; - rate-limit denial propagation to callers;
- pull streams backed by `IAsyncEnumerable<T>`; - pull streams backed by `IAsyncEnumerable<T>`;
- `TerminateExecution` through async-enumerator disposal; - `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. - parser allocation and collection budgets, attachment quotas, and per-IP connection limits.
Run it from the repository root: Run it from the repository root:
@@ -55,4 +56,22 @@ warehouse.Configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnecti
warehouse.Configuration.ResourceAttachments.RejectDuplicateAttachments = true; warehouse.Configuration.ResourceAttachments.RejectDuplicateAttachments = true;
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 64; 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 },
};
``` ```
+262
View File
@@ -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<byte>())));
}
[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<CryptographicException>(() => 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<CryptographicException>(() => 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<CryptographicException>(() => 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<CryptographicException>(() => 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<CryptographicException>(() =>
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<CryptographicException>(() =>
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<CryptographicException>(() =>
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<CryptographicException>(() =>
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<CryptographicException>(() =>
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<InvalidOperationException>(() => cipher.SetKey(SharedKey));
Assert.Throws<InvalidOperationException>(() => 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<CryptographicException>(() =>
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<ObjectDisposedException>(() => 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,
});
}
+56
View File
@@ -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<TargetInvocationException>(() =>
compose.Invoke(connection, new object[] { new byte[9] }));
Assert.IsType<ParserLimitException>(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;
}
}
@@ -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<InvalidOperationException>(() =>
warehouse.RegisterEncryptionProvider(new AesEncryptionProvider()));
}
}
+29
View File
@@ -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);
}
}
+9
View File
@@ -90,9 +90,12 @@ public class IndexedStructureTests
{ {
Version = (byte)3, Version = (byte)3,
Domain = "example.test", Domain = "example.test",
SupportedCiphers = new[] { "aes-gcm" },
CipherType = "aes-gcm",
IPAddress = new byte[] { 127, 0, 0, 1 }, IPAddress = new byte[] { 127, 0, 0, 1 },
AuthenticationProtocol = "hash", AuthenticationProtocol = "hash",
AuthenticationData = new byte[] { 1, 2, 3 }, AuthenticationData = new byte[] { 1, 2, 3 },
CipherNonce = Enumerable.Range(0, 32).Select(x => (byte)x).ToArray(),
}; };
var bytes = Codec.ComposeIndexedType(source, Warehouse.Default, null); var bytes = Codec.ComposeIndexedType(source, Warehouse.Default, null);
@@ -100,9 +103,12 @@ public class IndexedStructureTests
{ {
[(byte)EpAuthPacketHeader.Version] = source.Version, [(byte)EpAuthPacketHeader.Version] = source.Version,
[(byte)EpAuthPacketHeader.Domain] = source.Domain, [(byte)EpAuthPacketHeader.Domain] = source.Domain,
[(byte)EpAuthPacketHeader.SupportedCiphers] = source.SupportedCiphers,
[(byte)EpAuthPacketHeader.CipherType] = source.CipherType,
[(byte)EpAuthPacketHeader.IPAddress] = source.IPAddress, [(byte)EpAuthPacketHeader.IPAddress] = source.IPAddress,
[(byte)EpAuthPacketHeader.AuthenticationProtocol] = source.AuthenticationProtocol, [(byte)EpAuthPacketHeader.AuthenticationProtocol] = source.AuthenticationProtocol,
[(byte)EpAuthPacketHeader.AuthenticationData] = source.AuthenticationData, [(byte)EpAuthPacketHeader.AuthenticationData] = source.AuthenticationData,
[(byte)EpAuthPacketHeader.CipherNonce] = source.CipherNonce,
}, Warehouse.Default, null); }, Warehouse.Default, null);
var (_, raw) = Codec.ParseSync(bytes, 0, Warehouse.Default); var (_, raw) = Codec.ParseSync(bytes, 0, Warehouse.Default);
var map = Assert.IsType<Map<byte, object>>(raw); var map = Assert.IsType<Map<byte, object>>(raw);
@@ -116,6 +122,9 @@ public class IndexedStructureTests
Assert.Equal(source.IPAddress, parsed.IPAddress); Assert.Equal(source.IPAddress, parsed.IPAddress);
Assert.Equal(source.AuthenticationProtocol, parsed.AuthenticationProtocol); Assert.Equal(source.AuthenticationProtocol, parsed.AuthenticationProtocol);
Assert.Equal(source.AuthenticationData, parsed.AuthenticationData); 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 private sealed class InvalidStructure : IndexedStructure
@@ -0,0 +1,10 @@
using Esiur.Resource;
namespace Esiur.Tests.Unit.Integration;
[Resource]
public partial class EncryptedEchoResource
{
[Export]
public int Echo(int value) => value;
}
+98 -14
View File
@@ -6,6 +6,7 @@ using Esiur.Protocol;
using Esiur.Resource; using Esiur.Resource;
using Esiur.Security.Authority; using Esiur.Security.Authority;
using Esiur.Security.Authority.Providers; using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography;
using Esiur.Stores; using Esiur.Stores;
namespace Esiur.Tests.Unit.Integration; namespace Esiur.Tests.Unit.Integration;
@@ -34,6 +35,50 @@ internal class TestClientAuthProvider : PasswordAuthenticationProvider
: new IdentityPassword { Identity = null, Password = null }; : 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<bool> Login(Session session) => new AsyncReply<bool>(true);
public AsyncReply<bool> Logout(Session session) => new AsyncReply<bool>(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);
}
}
/// <summary> /// <summary>
/// Spins up an in-process Esiur server and an authenticated client connection over loopback TCP, /// 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 /// 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/&lt;rootPath&gt;" populated by /// Builds a server hosting resources under "sys/&lt;rootPath&gt;" populated by
/// <paramref name="populate"/>, opens it, then connects an authenticated client. /// <paramref name="populate"/>, opens it, then connects an authenticated client.
/// </summary> /// </summary>
public static async Task<IntegrationCluster> StartAsync(Func<Warehouse, Task> populate) public static async Task<IntegrationCluster> StartAsync(
Func<Warehouse, Task> 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 port = Interlocked.Increment(ref _portCounter);
var serverWh = new Warehouse(); 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()); await serverWh.Put("sys", new MemoryStore());
var server = await serverWh.Put("sys/server", new EpServer var server = await serverWh.Put("sys/server", new EpServer
{ {
Port = (ushort)port, Port = (ushort)port,
AllowedAuthenticationProviders = new[] { "hash" }, AllowedAuthenticationProviders = new[]
{
oneStepAuthentication ? "one-step" : "hash",
},
AllowedEncryptionProviders = (encrypted || requireEncryption) && allowEncryption
? new[] { AesEncryptionProvider.Name }
: Array.Empty<string>(),
RequireEncryption = requireEncryption,
}); });
await populate(serverWh); await populate(serverWh);
@@ -81,18 +145,38 @@ internal sealed class IntegrationCluster : IAsyncDisposable
var cluster = new IntegrationCluster(serverWh, server, port); var cluster = new IntegrationCluster(serverWh, server, port);
cluster.ClientWarehouse.RegisterAuthenticationProvider(new TestClientAuthProvider()); cluster.ClientWarehouse.RegisterAuthenticationProvider(oneStepAuthentication
cluster.Connection = await cluster.ClientWarehouse.Get<EpConnection>( ? new OneStepAuthenticationProvider(mismatchedSessionKeys ? (byte)0x80 : (byte)0)
$"ep://localhost:{port}", : new TestClientAuthProvider());
new EpConnectionContext if (encrypted)
{ cluster.ClientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
AuthenticationMode = AuthenticationMode.InitializerIdentity,
Identity = "tester",
AuthenticationProtocol = "hash",
Domain = "test",
});
return cluster; try
{
cluster.Connection = await cluster.ClientWarehouse.Get<EpConnection>(
$"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() public async ValueTask DisposeAsync()
@@ -1,5 +1,10 @@
namespace Esiur.Tests.Unit.Integration; namespace Esiur.Tests.Unit.Integration;
using Esiur.Data;
using Esiur.Net.Sockets;
using Esiur.Protocol;
using Esiur.Security.Cryptography;
[Collection("Integration")] [Collection("Integration")]
public class SessionHeadersIntegrationTests public class SessionHeadersIntegrationTests
{ {
@@ -19,4 +24,168 @@ public class SessionHeadersIntegrationTests
Assert.Equal("hash", serverConnection.Session.RemoteHeaders.AuthenticationProtocol); Assert.Equal("hash", serverConnection.Session.RemoteHeaders.AuthenticationProtocol);
Assert.Null(serverConnection.Session.RemoteHeaders.AuthenticationData); 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<Exception>(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<Exception>(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<byte, object> { [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<byte, object> { [0] = 203 });
Assert.True(cluster.Connection.IsEncrypted);
Assert.IsType<FrameworkWebSocket>(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<Exception>(async () =>
await IntegrationCluster.StartAsync(
_ => Task.CompletedTask,
encrypted: true,
oneStepAuthentication: true,
mismatchedSessionKeys: true)
.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.IsNotType<TimeoutException>(exception);
}
[Fact]
public async Task AddressBoundEncryption_RejectsWebSocketWithoutConcreteEndpoints()
{
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
await IntegrationCluster.StartAsync(
_ => Task.CompletedTask,
encrypted: true,
encryptionMode: EncryptionMode.EncryptWithSessionKeyAndAddress,
useWebSocket: true)
.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.IsNotType<TimeoutException>(exception);
}
} }