PPAP integration

This commit is contained in:
2026-07-16 19:36:30 +03:00
parent ba64a0c95a
commit 4cd9f928ff
36 changed files with 6586 additions and 656 deletions
@@ -0,0 +1,298 @@
using System.Threading;
using Esiur.Core;
using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Security.Authority;
using Esiur.Security.Cryptography;
using Esiur.Stores;
namespace Esiur.Tests.Unit.Integration;
[Collection("Integration")]
public class AuthenticationKeyRotationIntegrationTests
{
[Fact]
public async Task RequiredRotation_CompletesBeforeEncryptedConnectionBecomesReady()
{
var serverProvider = new TestRotatingAuthenticationProvider();
var clientProvider = new TestRotatingAuthenticationProvider();
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
serverProvider,
clientProvider,
encrypted: true,
populate: async warehouse =>
await warehouse.Put("sys/rotated", new Node { Id = 73 }))
.WaitAsync(TimeSpan.FromSeconds(10));
var serverConnection = Assert.Single(cluster.Server.Connections);
Assert.True(clientProvider.RotationStarted);
Assert.True(serverProvider.RotationCommitted);
Assert.True(cluster.Connection.Session.Authenticated);
Assert.True(serverConnection.Session.Authenticated);
Assert.True(cluster.Connection.IsEncrypted);
Assert.True(serverConnection.IsEncrypted);
var resource = await Task.Run(async () =>
await cluster.Connection.Get("sys/rotated"))
.WaitAsync(TimeSpan.FromSeconds(10));
Assert.NotNull(resource);
}
[Fact]
public async Task RequiredRotation_RejectsPlaintextTransport()
{
var serverProvider = new TestRotatingAuthenticationProvider();
var clientProvider = new TestRotatingAuthenticationProvider();
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
await CustomAuthenticationIntegrationCluster.StartAsync(
serverProvider,
clientProvider,
encrypted: false)
.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.IsNotType<TimeoutException>(exception);
Assert.False(clientProvider.RotationStarted);
Assert.False(serverProvider.RotationCommitted);
}
[Fact]
public async Task InvalidRotationProof_FailsClosedBeforeConnectionBecomesReady()
{
var serverProvider = new TestRotatingAuthenticationProvider();
var clientProvider = new TestRotatingAuthenticationProvider(corruptProof: true);
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
await CustomAuthenticationIntegrationCluster.StartAsync(
serverProvider,
clientProvider,
encrypted: true)
.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.IsNotType<TimeoutException>(exception);
Assert.True(clientProvider.RotationStarted);
Assert.True(serverProvider.RotationRejected);
Assert.False(serverProvider.RotationCommitted);
}
}
internal sealed class TestRotatingAuthenticationProvider : IAuthenticationProvider
{
readonly bool _corruptProof;
public TestRotatingAuthenticationProvider(bool corruptProof = false)
=> _corruptProof = corruptProof;
public string DefaultName => "rotating-test";
public bool RotationStarted { get; internal set; }
public bool RotationCommitted { get; internal set; }
public bool RotationRejected { get; internal set; }
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
=> new TestRotatingAuthenticationHandler(this, context.Direction, _corruptProof);
public AsyncReply<bool> Login(Session session) => new(true);
public AsyncReply<bool> Logout(Session session) => new(true);
}
internal sealed class TestRotatingAuthenticationHandler :
IAuthenticationHandler,
IAuthenticationKeyRotationHandler
{
const string BeginToken = "rotation-begin";
const string ChallengeToken = "rotation-challenge";
const string ProofToken = "rotation-proof";
const string InvalidProofToken = "rotation-proof-invalid";
const string AcknowledgementToken = "rotation-ack";
static readonly byte[] SessionKey =
Enumerable.Range(1, 64).Select(value => (byte)value).ToArray();
readonly TestRotatingAuthenticationProvider _provider;
readonly AuthenticationDirection _direction;
readonly bool _corruptProof;
int _rotationStep;
public TestRotatingAuthenticationHandler(
TestRotatingAuthenticationProvider provider,
AuthenticationDirection direction,
bool corruptProof)
{
_provider = provider;
_direction = direction;
_corruptProof = corruptProof;
}
public IAuthenticationProvider Provider => _provider;
public string Protocol => _provider.DefaultName;
public bool RequiresKeyRotation => true;
public AuthenticationResult Process(object authData)
=> new(
AuthenticationRuling.Succeeded,
authenticationData: Array.Empty<byte>(),
localIdentity: _direction == AuthenticationDirection.Initiator ? "client" : "server",
remoteIdentity: _direction == AuthenticationDirection.Initiator ? "server" : "client",
sessionKey: (byte[])SessionKey.Clone());
public AuthenticationKeyRotationResult BeginKeyRotation()
{
if (_direction != AuthenticationDirection.Initiator
|| Interlocked.CompareExchange(ref _rotationStep, 1, 0) != 0)
{
return Failed("Rotation was started in an invalid state.");
}
_provider.RotationStarted = true;
return InProgress(BeginToken);
}
public AuthenticationKeyRotationResult ProcessKeyRotation(object data)
{
if (_direction == AuthenticationDirection.Initiator)
{
if (_rotationStep != 1 || !String.Equals(data as string, ChallengeToken, StringComparison.Ordinal))
return Failed("The responder rotation challenge is invalid.");
_rotationStep = 2;
return Succeeded(_corruptProof ? InvalidProofToken : ProofToken);
}
if (_rotationStep == 0 && String.Equals(data as string, BeginToken, StringComparison.Ordinal))
{
_rotationStep = 1;
return InProgress(ChallengeToken);
}
if (_rotationStep == 1)
{
if (!String.Equals(data as string, ProofToken, StringComparison.Ordinal))
{
_provider.RotationRejected = true;
return Failed("The initiator rotation proof is invalid.");
}
_rotationStep = 2;
_provider.RotationCommitted = true;
return Succeeded(AcknowledgementToken);
}
return Failed("The rotation message arrived out of sequence.");
}
static AuthenticationKeyRotationResult InProgress(object data)
=> new(AuthenticationKeyRotationRuling.InProgress, data);
static AuthenticationKeyRotationResult Succeeded(object data)
=> new(AuthenticationKeyRotationRuling.Succeeded, data);
static AuthenticationKeyRotationResult Failed(string error)
=> new(AuthenticationKeyRotationRuling.Failed, error: error);
}
internal sealed class CustomAuthenticationIntegrationCluster : IAsyncDisposable
{
static int _portCounter = 16400;
public Warehouse ServerWarehouse { get; }
public Warehouse ClientWarehouse { get; }
public EpServer Server { get; }
public EpConnection Connection { get; private set; } = null!;
CustomAuthenticationIntegrationCluster(
Warehouse serverWarehouse,
Warehouse clientWarehouse,
EpServer server)
{
ServerWarehouse = serverWarehouse;
ClientWarehouse = clientWarehouse;
Server = server;
}
public static async Task<CustomAuthenticationIntegrationCluster> StartAsync(
IAuthenticationProvider serverProvider,
IAuthenticationProvider clientProvider,
bool encrypted,
Func<Warehouse, Task>? populate = null,
AuthenticationMode authenticationMode = AuthenticationMode.InitializerIdentity,
string? identity = "client",
string? responderIdentity = null,
string? domain = "test",
Action<EpServer>? serverCreated = null)
{
var port = Interlocked.Increment(ref _portCounter);
var serverWarehouse = new Warehouse();
var clientWarehouse = new Warehouse();
serverWarehouse.RegisterAuthenticationProvider(serverProvider);
clientWarehouse.RegisterAuthenticationProvider(clientProvider);
if (encrypted)
{
serverWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
clientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
}
await serverWarehouse.Put("sys", new MemoryStore());
var server = await serverWarehouse.Put("sys/server", new EpServer
{
Port = (ushort)port,
AllowedAuthenticationProviders = new[] { serverProvider.DefaultName },
AllowedEncryptionProviders = encrypted
? new[] { AesEncryptionProvider.Name }
: Array.Empty<string>(),
});
serverCreated?.Invoke(server);
if (populate != null)
await populate(serverWarehouse);
await serverWarehouse.Open();
var cluster = new CustomAuthenticationIntegrationCluster(
serverWarehouse,
clientWarehouse,
server);
try
{
var context = new EpConnectionContext
{
AuthenticationMode = authenticationMode,
AuthenticationProtocol = clientProvider.DefaultName,
Identity = identity!,
ResponderIdentity = responderIdentity!,
EncryptionMode = encrypted
? EncryptionMode.EncryptWithSessionKey
: EncryptionMode.None,
EncryptionProviders = new[] { AesEncryptionProvider.Name },
};
if (domain != null)
context.Domain = domain;
cluster.Connection = await clientWarehouse.Get<EpConnection>(
$"ep://localhost:{port}",
context);
return cluster;
}
catch
{
try { server.Destroy(); } catch { }
try { await clientWarehouse.Close(); } catch { }
try { await serverWarehouse.Close(); } catch { }
throw;
}
}
public async ValueTask DisposeAsync()
{
try { Connection.Destroy(); } catch { }
try { Server.Destroy(); } catch { }
try { await ClientWarehouse.Close(); } catch { }
try { await ServerWarehouse.Close(); } catch { }
await Task.Delay(50);
}
}
+109 -11
View File
@@ -1,4 +1,6 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Esiur.Core;
@@ -11,7 +13,7 @@ using Esiur.Stores;
namespace Esiur.Tests.Unit.Integration;
// ---- hash auth providers (self-consistent: client password {1..5} || server salt {6..10}
// ---- password auth providers (self-consistent: client password {1..5} || server salt {6..10}
// == {1..10}, which is what the server stores the hash of) ------------------------------
internal class TestServerAuthProvider : PasswordAuthenticationProvider
@@ -38,33 +40,53 @@ internal class TestClientAuthProvider : PasswordAuthenticationProvider
internal sealed class OneStepAuthenticationProvider : IAuthenticationProvider
{
readonly byte _keyMarker;
readonly bool _requiresKeyRotation;
public OneStepAuthenticationProvider(byte keyMarker = 0)
=> _keyMarker = keyMarker;
public OneStepAuthenticationProvider(byte keyMarker = 0, bool requiresKeyRotation = false)
{
_keyMarker = keyMarker;
_requiresKeyRotation = requiresKeyRotation;
}
public string DefaultName => "one-step";
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
=> new OneStepAuthenticationHandler(this, _keyMarker);
=> new OneStepAuthenticationHandler(
this,
_keyMarker,
context.Direction,
_requiresKeyRotation);
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
internal sealed class OneStepAuthenticationHandler :
IAuthenticationHandler,
IAuthenticationKeyRotationHandler
{
static readonly byte[] SharedKey = Enumerable.Range(1, 64).Select(x => (byte)x).ToArray();
readonly OneStepAuthenticationProvider _provider;
readonly byte _keyMarker;
readonly AuthenticationDirection _direction;
int _keyRotationStep;
public OneStepAuthenticationHandler(OneStepAuthenticationProvider provider, byte keyMarker)
public OneStepAuthenticationHandler(
OneStepAuthenticationProvider provider,
byte keyMarker,
AuthenticationDirection direction,
bool requiresKeyRotation)
{
_provider = provider;
_keyMarker = keyMarker;
_direction = direction;
RequiresKeyRotation = requiresKeyRotation;
}
public IAuthenticationProvider Provider => _provider;
public string Protocol => _provider.DefaultName;
public bool RequiresKeyRotation { get; }
public bool KeyRotationCompleted { get; private set; }
public AuthenticationResult Process(object authData)
{
@@ -77,6 +99,49 @@ internal sealed class OneStepAuthenticationHandler : IAuthenticationHandler
"server",
key);
}
public AuthenticationKeyRotationResult BeginKeyRotation()
{
if (!RequiresKeyRotation || _direction != AuthenticationDirection.Initiator)
return new(AuthenticationKeyRotationRuling.Failed, error: "Invalid key-rotation initiator.");
_keyRotationStep = 1;
return new(AuthenticationKeyRotationRuling.InProgress, new byte[] { 0xA1 });
}
public AuthenticationKeyRotationResult ProcessKeyRotation(object data)
{
if (!RequiresKeyRotation || data is not byte[] message || message.Length != 1)
return new(AuthenticationKeyRotationRuling.Failed, error: "Invalid key-rotation data.");
if (_direction == AuthenticationDirection.Responder
&& _keyRotationStep == 0
&& message[0] == 0xA1)
{
_keyRotationStep = 1;
return new(AuthenticationKeyRotationRuling.InProgress, new byte[] { 0xB2 });
}
if (_direction == AuthenticationDirection.Initiator
&& _keyRotationStep == 1
&& message[0] == 0xB2)
{
_keyRotationStep = 2;
KeyRotationCompleted = true;
return new(AuthenticationKeyRotationRuling.Succeeded, new byte[] { 0xC3 });
}
if (_direction == AuthenticationDirection.Responder
&& _keyRotationStep == 1
&& message[0] == 0xC3)
{
_keyRotationStep = 2;
KeyRotationCompleted = true;
return new(AuthenticationKeyRotationRuling.Succeeded);
}
return new(AuthenticationKeyRotationRuling.Failed, error: "Invalid key-rotation sequence.");
}
}
/// <summary>
@@ -88,6 +153,29 @@ internal sealed class IntegrationCluster : IAsyncDisposable
{
static int _portCounter = 14400;
static int NextAvailablePort()
{
while (true)
{
var candidate = Interlocked.Increment(ref _portCounter);
using var probe = new Socket(
AddressFamily.InterNetwork,
SocketType.Stream,
ProtocolType.Tcp);
try
{
probe.Bind(new IPEndPoint(IPAddress.Any, candidate));
return candidate;
}
catch (SocketException)
{
// Tests share the host with other applications. Skip ports already
// bound or reserved instead of making the suite environment-dependent.
}
}
}
public Warehouse ServerWarehouse { get; }
public Warehouse ClientWarehouse { get; }
public EpServer Server { get; }
@@ -115,15 +203,16 @@ internal sealed class IntegrationCluster : IAsyncDisposable
bool oneStepAuthentication = false,
bool useWebSocket = false,
bool mismatchedSessionKeys = false,
bool requireKeyRotation = false,
bool allowAuthentication = true,
bool registerServerAuthenticationProvider = true)
{
var port = Interlocked.Increment(ref _portCounter);
var port = NextAvailablePort();
var serverWh = new Warehouse();
if (registerServerAuthenticationProvider)
serverWh.RegisterAuthenticationProvider(oneStepAuthentication
? new OneStepAuthenticationProvider()
? new OneStepAuthenticationProvider(requiresKeyRotation: requireKeyRotation)
: new TestServerAuthProvider());
if (encrypted || requireEncryption)
serverWh.RegisterEncryptionProvider(new AesEncryptionProvider());
@@ -133,7 +222,12 @@ internal sealed class IntegrationCluster : IAsyncDisposable
{
Port = (ushort)port,
AllowedAuthenticationProviders = allowAuthentication
? new[] { oneStepAuthentication ? "one-step" : "hash" }
? new[]
{
oneStepAuthentication
? "one-step"
: PasswordAuthenticationProvider.ProtocolName,
}
: Array.Empty<string>(),
AllowedEncryptionProviders = (encrypted || requireEncryption) && allowEncryption
? new[] { AesEncryptionProvider.Name }
@@ -148,7 +242,9 @@ internal sealed class IntegrationCluster : IAsyncDisposable
var cluster = new IntegrationCluster(serverWh, server, port);
cluster.ClientWarehouse.RegisterAuthenticationProvider(oneStepAuthentication
? new OneStepAuthenticationProvider(mismatchedSessionKeys ? (byte)0x80 : (byte)0)
? new OneStepAuthenticationProvider(
mismatchedSessionKeys ? (byte)0x80 : (byte)0,
requireKeyRotation)
: new TestClientAuthProvider());
if (encrypted)
cluster.ClientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
@@ -161,7 +257,9 @@ internal sealed class IntegrationCluster : IAsyncDisposable
{
AuthenticationMode = AuthenticationMode.InitializerIdentity,
Identity = "tester",
AuthenticationProtocol = oneStepAuthentication ? "one-step" : "hash",
AuthenticationProtocol = oneStepAuthentication
? "one-step"
: PasswordAuthenticationProvider.ProtocolName,
Domain = "test",
EncryptionMode = encrypted
? encryptionMode
@@ -0,0 +1,336 @@
using Esiur.Security.Authority;
using Esiur.Security.Authority.Providers.Ppap;
namespace Esiur.Tests.Unit.Integration;
[Collection("Integration")]
public class PpapIntegrationTests
{
const string Domain = "ppap.test";
const string Identity = "alice";
const string Password = "correct horse battery staple";
const string ServerIdentity = "server";
const string ServerPassword = "server paper password";
static readonly PpapKdfProfile TestKdf = new(
PpapKdfProfile.Argon2Version13,
memoryKiB: 8 * 1024,
iterations: 1,
parallelism: 1);
[Fact]
public async Task InitializerPassword_AuthenticatesWithAesAndRotatesOnlyAfterEncryption()
{
using var localIdentity = PpapLocalIdentity.FromPassword(
Identity,
Password,
TestKdf);
var initial = PpapRegistrationRecord.FromLocalIdentity(Domain, localIdentity);
var serverStore = new ObservedPpapRegistrationStore();
Assert.True(serverStore.TryAdd(Domain, initial));
var clientProvider = new PpapAuthenticationProvider(
localIdentity,
new InMemoryPpapRegistrationStore());
var serverProvider = new PpapAuthenticationProvider(
localIdentity: null!,
registrations: serverStore);
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
serverProvider,
clientProvider,
encrypted: true,
populate: async warehouse =>
await warehouse.Put("sys/ppap-rotated", new Node { Id = 313 }),
authenticationMode: AuthenticationMode.InitializerIdentity,
identity: Identity,
domain: Domain,
serverCreated: value =>
{
serverStore.EncryptionProbe = () =>
value.Connections.SingleOrDefault()?.IsEncrypted == true;
})
.WaitAsync(TimeSpan.FromSeconds(15));
var serverConnection = Assert.Single(cluster.Server.Connections);
var rotated = Assert.IsType<PpapRegistrationRecord>(
serverStore.Get(Domain, Identity));
Assert.True(cluster.Connection.Session.Authenticated);
Assert.True(serverConnection.Session.Authenticated);
Assert.True(cluster.Connection.IsEncrypted);
Assert.True(serverConnection.IsEncrypted);
Assert.Equal(PpapProtocol.Name,
cluster.Connection.Session.AuthenticationHandler.Protocol);
Assert.Equal(PpapProtocol.SessionKeyLength,
cluster.Connection.Session.Key.Length);
Assert.Equal(cluster.Connection.Session.Key, serverConnection.Session.Key);
Assert.True(serverStore.EncryptionObservedAtSuccessfulRotation);
Assert.Equal(initial.Version + 1, rotated.Version);
Assert.False(initial.Nonce.SequenceEqual(rotated.Nonce));
Assert.False(initial.EncapsulationKey.SequenceEqual(rotated.EncapsulationKey));
Assert.NotNull(await Task.Run(async () =>
await cluster.Connection.Get("sys/ppap-rotated"))
.WaitAsync(TimeSpan.FromSeconds(10)));
}
[Fact]
public async Task InitializerPassword_OmittedDomainDefaultsToHostnameAndRotates()
{
const string hostnameDomain = "localhost";
using var localIdentity = PpapLocalIdentity.FromPassword(
Identity,
Password,
TestKdf);
var initial = PpapRegistrationRecord.FromLocalIdentity(
hostnameDomain,
localIdentity);
var serverStore = new ObservedPpapRegistrationStore();
Assert.True(serverStore.TryAdd(hostnameDomain, initial));
var clientProvider = new PpapAuthenticationProvider(
localIdentity,
new InMemoryPpapRegistrationStore());
var serverProvider = new PpapAuthenticationProvider(
localIdentity: null!,
registrations: serverStore);
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
serverProvider,
clientProvider,
encrypted: true,
authenticationMode: AuthenticationMode.InitializerIdentity,
identity: Identity,
domain: null,
serverCreated: value =>
{
serverStore.EncryptionProbe = () =>
value.Connections.SingleOrDefault()?.IsEncrypted == true;
})
.WaitAsync(TimeSpan.FromSeconds(15));
var serverConnection = Assert.Single(cluster.Server.Connections);
var rotated = Assert.IsType<PpapRegistrationRecord>(
serverStore.Get(hostnameDomain, Identity));
Assert.Equal(hostnameDomain, cluster.Connection.RemoteDomain);
Assert.True(cluster.Connection.Session.Authenticated);
Assert.True(serverConnection.Session.Authenticated);
Assert.True(cluster.Connection.IsEncrypted);
Assert.True(serverConnection.IsEncrypted);
Assert.True(serverStore.EncryptionObservedAtSuccessfulRotation);
Assert.Equal(initial.Version + 1, rotated.Version);
Assert.False(initial.Nonce.SequenceEqual(rotated.Nonce));
Assert.False(initial.EncapsulationKey.SequenceEqual(rotated.EncapsulationKey));
}
[Fact]
public async Task ResponderPassword_AuthenticatesWithAesAndRotatesResponderRegistration()
{
using var serverIdentity = PpapLocalIdentity.FromPassword(
ServerIdentity,
ServerPassword,
TestKdf);
var initial = PpapRegistrationRecord.FromLocalIdentity(
Domain,
serverIdentity);
var clientStore = new ObservedPpapRegistrationStore();
Assert.True(clientStore.TryAdd(Domain, initial));
var clientProvider = new PpapAuthenticationProvider(
localIdentity: null!,
registrations: clientStore);
var serverProvider = new PpapAuthenticationProvider(
serverIdentity,
new InMemoryPpapRegistrationStore());
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
serverProvider,
clientProvider,
encrypted: true,
authenticationMode: AuthenticationMode.ResponderIdentity,
identity: null,
responderIdentity: ServerIdentity,
domain: Domain,
serverCreated: value =>
{
clientStore.EncryptionProbe = () =>
value.Connections.SingleOrDefault()?.IsEncrypted == true;
})
.WaitAsync(TimeSpan.FromSeconds(15));
var serverConnection = Assert.Single(cluster.Server.Connections);
var rotated = Assert.IsType<PpapRegistrationRecord>(
clientStore.Get(Domain, ServerIdentity));
AssertEncryptedAndAuthenticated(cluster.Connection, serverConnection);
Assert.Null(cluster.Connection.Session.LocalIdentity);
Assert.Equal(ServerIdentity, cluster.Connection.Session.RemoteIdentity);
Assert.Equal(ServerIdentity, serverConnection.Session.LocalIdentity);
Assert.Null(serverConnection.Session.RemoteIdentity);
Assert.True(clientStore.EncryptionObservedAtSuccessfulRotation);
AssertRotated(initial, rotated);
}
[Fact]
public async Task DualPassword_AuthenticatesWithAesAndRotatesBothRegistrations()
{
using var clientIdentity = PpapLocalIdentity.FromPassword(
Identity,
Password,
TestKdf);
using var serverIdentity = PpapLocalIdentity.FromPassword(
ServerIdentity,
ServerPassword,
TestKdf);
var initialClient = PpapRegistrationRecord.FromLocalIdentity(
Domain,
clientIdentity);
var initialServer = PpapRegistrationRecord.FromLocalIdentity(
Domain,
serverIdentity);
var serverStore = new ObservedPpapRegistrationStore();
var clientStore = new ObservedPpapRegistrationStore();
Assert.True(serverStore.TryAdd(Domain, initialClient));
Assert.True(clientStore.TryAdd(Domain, initialServer));
var clientProvider = new PpapAuthenticationProvider(clientIdentity, clientStore);
var serverProvider = new PpapAuthenticationProvider(serverIdentity, serverStore);
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
serverProvider,
clientProvider,
encrypted: true,
authenticationMode: AuthenticationMode.DualIdentity,
identity: Identity,
responderIdentity: ServerIdentity,
domain: Domain,
serverCreated: value =>
{
Func<bool> probe = () =>
value.Connections.SingleOrDefault()?.IsEncrypted == true;
serverStore.EncryptionProbe = probe;
clientStore.EncryptionProbe = probe;
})
.WaitAsync(TimeSpan.FromSeconds(15));
var serverConnection = Assert.Single(cluster.Server.Connections);
AssertEncryptedAndAuthenticated(cluster.Connection, serverConnection);
Assert.Equal(Identity, cluster.Connection.Session.LocalIdentity);
Assert.Equal(ServerIdentity, cluster.Connection.Session.RemoteIdentity);
Assert.Equal(ServerIdentity, serverConnection.Session.LocalIdentity);
Assert.Equal(Identity, serverConnection.Session.RemoteIdentity);
Assert.True(serverStore.EncryptionObservedAtSuccessfulRotation);
Assert.True(clientStore.EncryptionObservedAtSuccessfulRotation);
AssertRotated(initialClient, serverStore.Get(Domain, Identity));
AssertRotated(initialServer, clientStore.Get(Domain, ServerIdentity));
}
[Fact]
public async Task DualMixedIdentity_RotatesOnlyPasswordRegistration()
{
using var clientIdentity = PpapLocalIdentity.CreateStatic(Identity);
using var serverIdentity = PpapLocalIdentity.FromPassword(
ServerIdentity,
ServerPassword,
TestKdf);
var initialClient = PpapRegistrationRecord.FromLocalIdentity(
Domain,
clientIdentity);
var initialServer = PpapRegistrationRecord.FromLocalIdentity(
Domain,
serverIdentity);
var serverStore = new ObservedPpapRegistrationStore();
var clientStore = new ObservedPpapRegistrationStore();
Assert.True(serverStore.TryAdd(Domain, initialClient));
Assert.True(clientStore.TryAdd(Domain, initialServer));
var clientProvider = new PpapAuthenticationProvider(clientIdentity, clientStore);
var serverProvider = new PpapAuthenticationProvider(serverIdentity, serverStore);
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
serverProvider,
clientProvider,
encrypted: true,
authenticationMode: AuthenticationMode.DualIdentity,
identity: Identity,
responderIdentity: ServerIdentity,
domain: Domain,
serverCreated: value =>
{
Func<bool> probe = () =>
value.Connections.SingleOrDefault()?.IsEncrypted == true;
serverStore.EncryptionProbe = probe;
clientStore.EncryptionProbe = probe;
})
.WaitAsync(TimeSpan.FromSeconds(15));
var serverConnection = Assert.Single(cluster.Server.Connections);
AssertEncryptedAndAuthenticated(cluster.Connection, serverConnection);
var unchangedClient = serverStore.Get(Domain, Identity);
Assert.Equal(initialClient.Version, unchangedClient.Version);
Assert.Equal(initialClient.EncapsulationKey, unchangedClient.EncapsulationKey);
Assert.False(serverStore.EncryptionObservedAtSuccessfulRotation);
Assert.True(clientStore.EncryptionObservedAtSuccessfulRotation);
AssertRotated(initialServer, clientStore.Get(Domain, ServerIdentity));
}
static void AssertEncryptedAndAuthenticated(
Esiur.Protocol.EpConnection client,
Esiur.Protocol.EpConnection server)
{
Assert.True(client.Session.Authenticated);
Assert.True(server.Session.Authenticated);
Assert.True(client.IsEncrypted);
Assert.True(server.IsEncrypted);
Assert.Equal(client.Session.Key, server.Session.Key);
}
static void AssertRotated(
PpapRegistrationRecord initial,
PpapRegistrationRecord rotated)
{
Assert.NotNull(rotated);
Assert.Equal(initial.Version + 1, rotated.Version);
Assert.False(initial.Nonce.SequenceEqual(rotated.Nonce));
Assert.False(initial.EncapsulationKey.SequenceEqual(rotated.EncapsulationKey));
}
}
internal sealed class ObservedPpapRegistrationStore : IPpapRegistrationStore
{
readonly InMemoryPpapRegistrationStore _inner = new();
int _encryptionObservedAtSuccessfulRotation;
public Func<bool>? EncryptionProbe { get; set; }
public bool EncryptionObservedAtSuccessfulRotation
=> Volatile.Read(ref _encryptionObservedAtSuccessfulRotation) != 0;
public bool TryAdd(string domain, PpapRegistrationRecord record)
=> _inner.TryAdd(domain, record);
public PpapRegistrationRecord Get(string domain, string identity)
=> _inner.Get(domain, identity);
public PpapRegistrationRecord ResolveMasked(
string domain,
byte[] mask,
byte[] maskKey,
byte[] maskedIdentity)
=> _inner.ResolveMasked(domain, mask, maskKey, maskedIdentity);
public bool TryRotate(
string domain,
string identity,
long expectedVersion,
PpapRegistrationRecord replacement)
{
var encrypted = EncryptionProbe?.Invoke() == true;
var rotated = _inner.TryRotate(
domain,
identity,
expectedVersion,
replacement);
if (rotated && encrypted)
Interlocked.Exchange(ref _encryptionObservedAtSuccessfulRotation, 1);
return rotated;
}
}
@@ -3,6 +3,7 @@ namespace Esiur.Tests.Unit.Integration;
using Esiur.Data;
using Esiur.Net.Sockets;
using Esiur.Protocol;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography;
[Collection("Integration")]
@@ -21,7 +22,8 @@ public class SessionHeadersIntegrationTests
Assert.Null(cluster.Connection.Session.RemoteHeaders.AuthenticationData);
Assert.Equal("test", serverConnection.Session.RemoteHeaders.Domain);
Assert.Equal("hash", serverConnection.Session.RemoteHeaders.AuthenticationProtocol);
Assert.Equal(PasswordAuthenticationProvider.ProtocolName,
serverConnection.Session.RemoteHeaders.AuthenticationProtocol);
Assert.Null(serverConnection.Session.RemoteHeaders.AuthenticationData);
}
@@ -159,6 +161,50 @@ public class SessionHeadersIntegrationTests
Assert.Equal(117, Convert.ToInt32(result));
}
[Fact]
public async Task RequiredAuthenticationKeyRotation_CompletesBeforeConnectionsBecomeReady()
{
await using var cluster = await IntegrationCluster
.StartAsync(
async warehouse =>
{
await warehouse.Put("sys/key-rotation", new EncryptedEchoResource());
},
encrypted: true,
oneStepAuthentication: true,
requireKeyRotation: true)
.WaitAsync(TimeSpan.FromSeconds(10));
var clientHandler = Assert.IsType<OneStepAuthenticationHandler>(
cluster.Connection.Session.AuthenticationHandler);
var serverConnection = Assert.Single(cluster.Server.Connections);
var serverHandler = Assert.IsType<OneStepAuthenticationHandler>(
serverConnection.Session.AuthenticationHandler);
Assert.True(clientHandler.KeyRotationCompleted);
Assert.True(serverHandler.KeyRotationCompleted);
Assert.True(cluster.Connection.IsEncrypted);
Assert.True(serverConnection.IsEncrypted);
Assert.NotNull(await Task.Run(async () =>
await cluster.Connection.Get("sys/key-rotation"))
.WaitAsync(TimeSpan.FromSeconds(10)));
}
[Fact]
public async Task RequiredAuthenticationKeyRotation_RejectsUnencryptedSession()
{
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
await IntegrationCluster.StartAsync(
_ => Task.CompletedTask,
encrypted: false,
oneStepAuthentication: true,
requireKeyRotation: true)
.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.IsNotType<TimeoutException>(exception);
}
[Fact]
public async Task WebSocketTransport_EncryptsFetchAndRpc()
{