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
+8
View File
@@ -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<MyService> 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<EpConnection> 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 },
});
}
+20 -1
View File
@@ -12,7 +12,8 @@ Coverage includes:
- rate-limit denial propagation to callers;
- pull streams backed by `IAsyncEnumerable<T>`;
- `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 },
};
```
+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,
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<Map<byte, object>>(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
@@ -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.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<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>
/// 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/&lt;rootPath&gt;" populated by
/// <paramref name="populate"/>, opens it, then connects an authenticated client.
/// </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 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<string>(),
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<EpConnection>(
$"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<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()
@@ -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<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);
}
}