mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
PPAP integration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user