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
@@ -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);
}
}