mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-31 01:40:42 +00:00
PPAP integration
This commit is contained in:
@@ -27,6 +27,7 @@ using Esiur.Data;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
using Esiur.Security.Cryptography;
|
||||
using Esiur.Security.Management;
|
||||
using Esiur.Security.RateLimiting;
|
||||
@@ -120,7 +121,10 @@ internal static class Program
|
||||
var server = await warehouse.Put("sys/server", new EpServer
|
||||
{
|
||||
Port = port,
|
||||
AllowedAuthenticationProviders = new[] { "hash" },
|
||||
AllowedAuthenticationProviders = new[]
|
||||
{
|
||||
PasswordAuthenticationProvider.ProtocolName,
|
||||
},
|
||||
AllowedEncryptionProviders = new[] { AesEncryptionProvider.Name },
|
||||
RequireEncryption = true,
|
||||
});
|
||||
@@ -185,7 +189,7 @@ internal static class Program
|
||||
AuthenticationMode = AuthenticationMode.InitializerIdentity,
|
||||
AutoReconnect = false,
|
||||
Identity = "tester",
|
||||
AuthenticationProtocol = "hash",
|
||||
AuthenticationProtocol = PasswordAuthenticationProvider.ProtocolName,
|
||||
Domain = "test",
|
||||
EncryptionMode = EncryptionMode.EncryptWithSessionKey,
|
||||
EncryptionProviders = new[] { AesEncryptionProvider.Name },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
using Esiur.Security.Cryptography;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
@@ -26,7 +27,7 @@ public class AesEncryptionProviderTests
|
||||
var record = initiator.Encrypt("Esiur AES-GCM vector"u8.ToArray());
|
||||
|
||||
Assert.Equal(
|
||||
"0000000000000000BF5D9D7DA3D6D5232578F966450E5B96B89172D4F186A5C45299C42D8ABB732A07B1E092",
|
||||
"0000000000000000D9538F565DFB97D51FE9AA53316A1FE2D5EEF3361A8958E4439C0BE21A6FC840A3DF42F9",
|
||||
Convert.ToHexString(record));
|
||||
}
|
||||
|
||||
@@ -145,8 +146,8 @@ public class AesEncryptionProviderTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AuthenticationMode.DualIdentity, "hash")]
|
||||
[InlineData(AuthenticationMode.InitializerIdentity, "hash-v2")]
|
||||
[InlineData(AuthenticationMode.DualIdentity, "password-sha3-v1")]
|
||||
[InlineData(AuthenticationMode.InitializerIdentity, "password-sha3-v2")]
|
||||
public void AuthenticationNegotiation_IsBoundIntoKeyDerivation(
|
||||
AuthenticationMode responderMode,
|
||||
string responderProtocol)
|
||||
@@ -242,7 +243,7 @@ public class AesEncryptionProviderTests
|
||||
string protocol = AesEncryptionProvider.Name,
|
||||
string[]? offeredProtocols = null,
|
||||
AuthenticationMode authenticationMode = AuthenticationMode.InitializerIdentity,
|
||||
string authenticationProtocol = "hash",
|
||||
string authenticationProtocol = PasswordAuthenticationProvider.ProtocolName,
|
||||
string domain = "example.test")
|
||||
=> (AesGcmSymetricCipher)_provider.CreateCipher(new EncryptionContext
|
||||
{
|
||||
|
||||
@@ -82,6 +82,22 @@ public class AuthHandshakeTests
|
||||
|
||||
// ---- happy path ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ProviderAndHandler_UseVersionedPasswordProtocolName()
|
||||
{
|
||||
var provider = new PasswordAuthenticationProvider();
|
||||
var handler = provider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = AuthenticationDirection.Initiator,
|
||||
Mode = AuthenticationMode.InitializerIdentity,
|
||||
Domain = "domain",
|
||||
HostName = "host",
|
||||
});
|
||||
|
||||
Assert.Equal("password-sha3-v1", provider.DefaultName);
|
||||
Assert.Equal(provider.DefaultName, handler.Protocol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializerIdentity_Handshake_Derives_Matching_SessionKeys()
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using Esiur.Data;
|
||||
using Esiur.Net.Packets;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
@@ -93,7 +94,7 @@ public class IndexedStructureTests
|
||||
SupportedCiphers = new[] { "aes-gcm" },
|
||||
CipherType = "aes-gcm",
|
||||
IPAddress = new byte[] { 127, 0, 0, 1 },
|
||||
AuthenticationProtocol = "hash",
|
||||
AuthenticationProtocol = PasswordAuthenticationProvider.ProtocolName,
|
||||
AuthenticationData = new byte[] { 1, 2, 3 },
|
||||
CipherNonce = Enumerable.Range(0, 32).Select(x => (byte)x).ToArray(),
|
||||
};
|
||||
@@ -116,7 +117,8 @@ public class IndexedStructureTests
|
||||
|
||||
Assert.Equal(legacyBytes, bytes);
|
||||
Assert.Equal("example.test", map[(byte)EpAuthPacketHeader.Domain]);
|
||||
Assert.Equal("hash", map[(byte)EpAuthPacketHeader.AuthenticationProtocol]);
|
||||
Assert.Equal(PasswordAuthenticationProvider.ProtocolName,
|
||||
map[(byte)EpAuthPacketHeader.AuthenticationProtocol]);
|
||||
Assert.False(map.ContainsKey((byte)EpAuthPacketHeader.ErrorMessage));
|
||||
Assert.Equal(source.Domain, parsed.Domain);
|
||||
Assert.Equal(source.IPAddress, parsed.IPAddress);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -49,6 +49,103 @@ public class PeerConnectionLimitTests
|
||||
replacementSocket.Close();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_RejectsRepeatedConnectionsAbovePerIpAttemptRate()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 0;
|
||||
warehouse.Configuration.Connections.MaximumConnectionAttemptsPerIpAddress = 2;
|
||||
warehouse.Configuration.Connections.ConnectionAttemptWindow = TimeSpan.FromMinutes(1);
|
||||
|
||||
var server = new EpServer();
|
||||
server.Instance = new Instance(warehouse, 1, "server", server, null);
|
||||
server.Connections = new AutoList<EpConnection, NetworkServer<EpConnection>>(server);
|
||||
var address = IPAddress.Parse("192.0.2.20");
|
||||
|
||||
for (var index = 0; index < 2; index++)
|
||||
{
|
||||
var admittedSocket = new TestSocket(address, 11000 + index);
|
||||
var admitted = new EpConnection();
|
||||
admitted.Assign(admittedSocket);
|
||||
server.Add(admitted);
|
||||
Assert.Equal(SocketState.Established, admittedSocket.State);
|
||||
admittedSocket.Close();
|
||||
}
|
||||
|
||||
var rejectedSocket = new TestSocket(address, 11002);
|
||||
var rejected = new EpConnection();
|
||||
rejected.Assign(rejectedSocket);
|
||||
server.Add(rejected);
|
||||
|
||||
Assert.Equal(SocketState.Closed, rejectedSocket.State);
|
||||
Assert.Empty(server.Connections);
|
||||
|
||||
var otherSocket = new TestSocket(IPAddress.Parse("192.0.2.21"), 11003);
|
||||
var other = new EpConnection();
|
||||
other.Assign(otherSocket);
|
||||
server.Add(other);
|
||||
Assert.Equal(SocketState.Established, otherSocket.State);
|
||||
otherSocket.Close();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_ClosesStalledAuthenticationAtDeadlineAndReleasesSlot()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 1;
|
||||
var server = new EpServer
|
||||
{
|
||||
AuthenticationTimeout = TimeSpan.FromMilliseconds(50),
|
||||
};
|
||||
server.Instance = new Instance(warehouse, 1, "server", server, null);
|
||||
server.Connections = new AutoList<EpConnection, NetworkServer<EpConnection>>(server);
|
||||
|
||||
var address = IPAddress.Parse("192.0.2.30");
|
||||
var socket = new TestSocket(address, 12000);
|
||||
var connection = new EpConnection();
|
||||
connection.Assign(socket);
|
||||
server.Add(connection);
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3);
|
||||
while ((socket.State != SocketState.Closed
|
||||
|| server.GetConnectionCount(address) != 0
|
||||
|| server.Connections.Count != 0)
|
||||
&& DateTime.UtcNow < deadline)
|
||||
await Task.Delay(10);
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.Equal(0, server.GetConnectionCount(address));
|
||||
Assert.Empty(server.Connections);
|
||||
Assert.False(connection.Session.Authenticated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_ClosesStalledAuthenticationAtDeadline()
|
||||
{
|
||||
var socket = new TestSocket(IPAddress.Parse("192.0.2.40"), 13000);
|
||||
var connection = new EpConnection
|
||||
{
|
||||
AuthenticationTimeout = TimeSpan.FromMilliseconds(50),
|
||||
};
|
||||
var warehouse = new Warehouse();
|
||||
connection.Instance = new Instance(
|
||||
warehouse,
|
||||
1,
|
||||
"example.test",
|
||||
connection,
|
||||
null);
|
||||
|
||||
_ = connection.Connect(socket, "example.test", 10518, "example.test");
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3);
|
||||
while (socket.State != SocketState.Closed && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(10);
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.False(connection.Session.Authenticated);
|
||||
Assert.Equal(EpConnectionStatus.Closed, connection.Status);
|
||||
}
|
||||
|
||||
sealed class TestSocket : ISocket
|
||||
{
|
||||
public event DestroyedEvent? OnDestroy;
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
using System.Buffers.Binary;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class PpapAuthenticationTests
|
||||
{
|
||||
const string Domain = "example.test";
|
||||
const string InitiatorIdentity = "alice";
|
||||
const string ResponderIdentity = "server";
|
||||
const string InitiatorPassword = "correct horse battery staple";
|
||||
const string ResponderPassword = "server paper password";
|
||||
|
||||
static readonly PpapKdfProfile TestKdf = new(
|
||||
PpapKdfProfile.Argon2Version13,
|
||||
memoryKiB: 8 * 1024,
|
||||
iterations: 1,
|
||||
parallelism: 1);
|
||||
|
||||
[Theory]
|
||||
[InlineData(AuthenticationMode.InitializerIdentity)]
|
||||
[InlineData(AuthenticationMode.ResponderIdentity)]
|
||||
[InlineData(AuthenticationMode.DualIdentity)]
|
||||
public void PasswordHandshake_AllIdentityModesDeriveMatchingKeys(
|
||||
AuthenticationMode mode)
|
||||
{
|
||||
using var pair = CreatePair(mode);
|
||||
|
||||
var result = CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
|
||||
var authenticatesInitiator = mode is AuthenticationMode.InitializerIdentity
|
||||
or AuthenticationMode.DualIdentity;
|
||||
var authenticatesResponder = mode is AuthenticationMode.ResponderIdentity
|
||||
or AuthenticationMode.DualIdentity;
|
||||
|
||||
Assert.Equal(PpapProtocol.SessionKeyLength, result.Initiator.SessionKey.Length);
|
||||
Assert.Equal(result.Initiator.SessionKey, result.Responder.SessionKey);
|
||||
Assert.Equal(authenticatesInitiator ? InitiatorIdentity : null,
|
||||
result.Initiator.LocalIdentity);
|
||||
Assert.Equal(authenticatesResponder ? ResponderIdentity : null,
|
||||
result.Initiator.RemoteIdentity);
|
||||
Assert.Equal(authenticatesResponder ? ResponderIdentity : null,
|
||||
result.Responder.LocalIdentity);
|
||||
Assert.Equal(authenticatesInitiator ? InitiatorIdentity : null,
|
||||
result.Responder.RemoteIdentity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongPassword_FailsWithoutChangingRegistration()
|
||||
{
|
||||
using var pair = CreatePair(
|
||||
AuthenticationMode.InitializerIdentity,
|
||||
initiatorPassword: "not the registered password");
|
||||
var before = Snapshot(pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(Wire(second, PpapMessageType.ServerHello));
|
||||
var fourth = pair.Responder.Process(Wire(third, PpapMessageType.InitiatorProof));
|
||||
var failed = pair.Initiator.Process(Wire(fourth, PpapMessageType.ResponderProof));
|
||||
|
||||
Assert.Equal(AuthenticationRuling.Failed, failed.Ruling);
|
||||
Assert.Null(failed.SessionKey);
|
||||
AssertSnapshot(before, pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedResponderFinished_FailsClosed()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.InitializerIdentity);
|
||||
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(Wire(second, PpapMessageType.ServerHello));
|
||||
var fourth = pair.Responder.Process(Wire(third, PpapMessageType.InitiatorProof));
|
||||
var tampered = TamperLastByte(Wire(fourth, PpapMessageType.ResponderProof));
|
||||
|
||||
var failed = pair.Initiator.Process(tampered);
|
||||
|
||||
Assert.Equal(AuthenticationRuling.Failed, failed.Ruling);
|
||||
Assert.Null(failed.SessionKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedInitiatorFinished_FailsClosed()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.InitializerIdentity);
|
||||
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(Wire(second, PpapMessageType.ServerHello));
|
||||
var fourth = pair.Responder.Process(Wire(third, PpapMessageType.InitiatorProof));
|
||||
var fifth = pair.Initiator.Process(Wire(fourth, PpapMessageType.ResponderProof));
|
||||
var tampered = TamperLastByte(Wire(fifth, PpapMessageType.InitiatorFinished));
|
||||
|
||||
var failed = pair.Responder.Process(tampered);
|
||||
|
||||
Assert.Equal(AuthenticationRuling.Failed, failed.Ruling);
|
||||
Assert.Null(failed.SessionKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DualIdentityProofs_DoNotExposeRegistrationDescriptors()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.DualIdentity);
|
||||
var initiatorRegistration = pair.ResponderStore.Get(
|
||||
Domain,
|
||||
InitiatorIdentity);
|
||||
var responderRegistration = pair.InitiatorStore.Get(
|
||||
Domain,
|
||||
ResponderIdentity);
|
||||
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(
|
||||
Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(
|
||||
Wire(second, PpapMessageType.ServerHello));
|
||||
var initiatorProof = Wire(third, PpapMessageType.InitiatorProof);
|
||||
var fourth = pair.Responder.Process(initiatorProof);
|
||||
var responderProof = Wire(fourth, PpapMessageType.ResponderProof);
|
||||
|
||||
AssertDescriptorNotVisible(initiatorProof, responderRegistration);
|
||||
AssertDescriptorNotVisible(responderProof, initiatorRegistration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedEncryptedResponderDescriptor_FailsClosed()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.DualIdentity);
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(
|
||||
Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(
|
||||
Wire(second, PpapMessageType.ServerHello));
|
||||
var tampered = TamperField(
|
||||
Wire(third, PpapMessageType.InitiatorProof),
|
||||
fieldIndex: 2);
|
||||
|
||||
var failed = pair.Responder.Process(tampered);
|
||||
|
||||
Assert.Equal(AuthenticationRuling.Failed, failed.Ruling);
|
||||
Assert.Null(failed.SessionKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedEncryptedInitiatorDescriptor_FailsClosed()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.DualIdentity);
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(
|
||||
Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(
|
||||
Wire(second, PpapMessageType.ServerHello));
|
||||
var fourth = pair.Responder.Process(
|
||||
Wire(third, PpapMessageType.InitiatorProof));
|
||||
var tampered = TamperField(
|
||||
Wire(fourth, PpapMessageType.ResponderProof),
|
||||
fieldIndex: 1);
|
||||
|
||||
var failed = pair.Initiator.Process(tampered);
|
||||
|
||||
Assert.Equal(AuthenticationRuling.Failed, failed.Ruling);
|
||||
Assert.Null(failed.SessionKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtectedDescriptor_DiffersAcrossFreshSessionsForSameRegistration()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.DualIdentity);
|
||||
var firstProof = CreateInitiatorProof(pair.Initiator, pair.Responder);
|
||||
var initiatorProvider = Assert.IsType<PpapAuthenticationProvider>(
|
||||
pair.Initiator.Provider);
|
||||
var responderProvider = Assert.IsType<PpapAuthenticationProvider>(
|
||||
pair.Responder.Provider);
|
||||
using var secondInitiator = CreateHandler(
|
||||
initiatorProvider,
|
||||
AuthenticationDirection.Initiator,
|
||||
AuthenticationMode.DualIdentity);
|
||||
using var secondResponder = CreateHandler(
|
||||
responderProvider,
|
||||
AuthenticationDirection.Responder,
|
||||
AuthenticationMode.DualIdentity);
|
||||
var secondProof = CreateInitiatorProof(secondInitiator, secondResponder);
|
||||
|
||||
var firstDescriptor = ExtractField(firstProof, fieldIndex: 2);
|
||||
var secondDescriptor = ExtractField(secondProof, fieldIndex: 2);
|
||||
|
||||
Assert.Equal(144, firstDescriptor.Length);
|
||||
Assert.Equal(PpapProtocol.ProtectedDescriptorLength, firstDescriptor.Length);
|
||||
Assert.Equal(144, secondDescriptor.Length);
|
||||
Assert.False(firstDescriptor.SequenceEqual(secondDescriptor));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializerPassword_RotationChangesVersionNonceAndKey()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.InitializerIdentity);
|
||||
CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
var before = Snapshot(pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
|
||||
CompleteInitializerRotation(pair.Initiator, pair.Responder);
|
||||
|
||||
var after = pair.ResponderStore.Get(Domain, InitiatorIdentity);
|
||||
Assert.Equal(before.Version + 1, after.Version);
|
||||
Assert.False(before.Nonce.SequenceEqual(after.Nonce));
|
||||
Assert.False(before.EncapsulationKey.SequenceEqual(after.EncapsulationKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponderPassword_RotationChangesVerifierRecord()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.ResponderIdentity);
|
||||
CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
var before = Snapshot(pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
|
||||
CompleteResponderRotation(pair.Initiator, pair.Responder);
|
||||
|
||||
AssertRotated(
|
||||
before,
|
||||
pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DualPassword_RotationChangesBothVerifierRecords()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.DualIdentity);
|
||||
CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
var initiatorBefore = Snapshot(
|
||||
pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
var responderBefore = Snapshot(
|
||||
pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
|
||||
CompleteDualRotation(pair.Initiator, pair.Responder);
|
||||
|
||||
AssertRotated(
|
||||
initiatorBefore,
|
||||
pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
AssertRotated(
|
||||
responderBefore,
|
||||
pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedRotationProof_FailsWithoutPersistingOffer()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.InitializerIdentity);
|
||||
CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
var before = Snapshot(pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
|
||||
var offer = pair.Initiator.BeginKeyRotation();
|
||||
var challenge = pair.Responder.ProcessKeyRotation(
|
||||
RotationWire(offer, PpapMessageType.RotationOffer));
|
||||
var proof = pair.Initiator.ProcessKeyRotation(
|
||||
RotationWire(challenge, PpapMessageType.RotationChallenge));
|
||||
var tampered = TamperLastByte(
|
||||
RotationWire(proof, PpapMessageType.RotationProof));
|
||||
|
||||
var failed = pair.Responder.ProcessKeyRotation(tampered);
|
||||
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Failed, failed.Ruling);
|
||||
AssertSnapshot(before, pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticDualIdentity_UsesEncryptedNoOpCompletionWithoutChangingRecords()
|
||||
{
|
||||
using var pair = CreateStaticPair();
|
||||
var initiatorBefore = Snapshot(
|
||||
pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
var responderBefore = Snapshot(
|
||||
pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
|
||||
var done = pair.Initiator.BeginKeyRotation();
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, done.Ruling);
|
||||
var completed = pair.Responder.ProcessKeyRotation(
|
||||
RotationWire(done, PpapMessageType.RotationDone));
|
||||
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, completed.Ruling);
|
||||
Assert.Null(completed.Data);
|
||||
AssertSnapshot(
|
||||
initiatorBefore,
|
||||
pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
AssertSnapshot(
|
||||
responderBefore,
|
||||
pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
}
|
||||
|
||||
static HandshakeResult CompleteHandshake(
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
var first = initiator.Process(null!);
|
||||
Assert.Equal(AuthenticationRuling.InProgress, first.Ruling);
|
||||
|
||||
var second = responder.Process(Wire(first, PpapMessageType.ClientHello));
|
||||
Assert.Equal(AuthenticationRuling.InProgress, second.Ruling);
|
||||
|
||||
var third = initiator.Process(Wire(second, PpapMessageType.ServerHello));
|
||||
Assert.Equal(AuthenticationRuling.InProgress, third.Ruling);
|
||||
|
||||
var fourth = responder.Process(Wire(third, PpapMessageType.InitiatorProof));
|
||||
Assert.Equal(AuthenticationRuling.InProgress, fourth.Ruling);
|
||||
|
||||
var fifth = initiator.Process(Wire(fourth, PpapMessageType.ResponderProof));
|
||||
Assert.Equal(AuthenticationRuling.Succeeded, fifth.Ruling);
|
||||
|
||||
var sixth = responder.Process(Wire(fifth, PpapMessageType.InitiatorFinished));
|
||||
Assert.Equal(AuthenticationRuling.Succeeded, sixth.Ruling);
|
||||
|
||||
return new HandshakeResult(fifth, sixth);
|
||||
}
|
||||
|
||||
static byte[] CreateInitiatorProof(
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
var first = initiator.Process(null!);
|
||||
var second = responder.Process(Wire(first, PpapMessageType.ClientHello));
|
||||
var third = initiator.Process(Wire(second, PpapMessageType.ServerHello));
|
||||
return Wire(third, PpapMessageType.InitiatorProof);
|
||||
}
|
||||
|
||||
static PpapAuthenticationHandler CreateHandler(
|
||||
PpapAuthenticationProvider provider,
|
||||
AuthenticationDirection direction,
|
||||
AuthenticationMode mode)
|
||||
=> Assert.IsType<PpapAuthenticationHandler>(
|
||||
provider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = direction,
|
||||
Mode = mode,
|
||||
Domain = Domain,
|
||||
InitiatorIdentity = InitiatorIdentity,
|
||||
ResponderIdentity = ResponderIdentity,
|
||||
HostName = ResponderIdentity,
|
||||
}));
|
||||
|
||||
static void CompleteInitializerRotation(
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
Assert.True(initiator.RequiresKeyRotation);
|
||||
Assert.True(responder.RequiresKeyRotation);
|
||||
|
||||
var offer = initiator.BeginKeyRotation();
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.InProgress, offer.Ruling);
|
||||
|
||||
var challenge = responder.ProcessKeyRotation(
|
||||
RotationWire(offer, PpapMessageType.RotationOffer));
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.InProgress, challenge.Ruling);
|
||||
|
||||
var proof = initiator.ProcessKeyRotation(
|
||||
RotationWire(challenge, PpapMessageType.RotationChallenge));
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.InProgress, proof.Ruling);
|
||||
|
||||
var commit = responder.ProcessKeyRotation(
|
||||
RotationWire(proof, PpapMessageType.RotationProof));
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.InProgress, commit.Ruling);
|
||||
|
||||
var done = initiator.ProcessKeyRotation(
|
||||
RotationWire(commit, PpapMessageType.RotationCommit));
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, done.Ruling);
|
||||
|
||||
var completed = responder.ProcessKeyRotation(
|
||||
RotationWire(done, PpapMessageType.RotationDone));
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, completed.Ruling);
|
||||
Assert.Null(completed.Data);
|
||||
}
|
||||
|
||||
static void CompleteResponderRotation(
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
var start = initiator.BeginKeyRotation();
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.InProgress, start.Ruling);
|
||||
|
||||
var offer = responder.ProcessKeyRotation(
|
||||
RotationWire(start, PpapMessageType.RotationStart));
|
||||
var challenge = initiator.ProcessKeyRotation(
|
||||
RotationWire(offer, PpapMessageType.RotationOffer));
|
||||
var proof = responder.ProcessKeyRotation(
|
||||
RotationWire(challenge, PpapMessageType.RotationChallenge));
|
||||
var commit = initiator.ProcessKeyRotation(
|
||||
RotationWire(proof, PpapMessageType.RotationProof));
|
||||
var acknowledgement = responder.ProcessKeyRotation(
|
||||
RotationWire(commit, PpapMessageType.RotationCommit));
|
||||
var done = initiator.ProcessKeyRotation(
|
||||
RotationWire(acknowledgement, PpapMessageType.RotationCommitAck));
|
||||
var completed = responder.ProcessKeyRotation(
|
||||
RotationWire(done, PpapMessageType.RotationDone));
|
||||
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, done.Ruling);
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, completed.Ruling);
|
||||
}
|
||||
|
||||
static void CompleteDualRotation(
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
var initiatorOffer = initiator.BeginKeyRotation();
|
||||
var initiatorChallenge = responder.ProcessKeyRotation(
|
||||
RotationWire(initiatorOffer, PpapMessageType.RotationOffer));
|
||||
var initiatorProof = initiator.ProcessKeyRotation(
|
||||
RotationWire(initiatorChallenge, PpapMessageType.RotationChallenge));
|
||||
var initiatorCommit = responder.ProcessKeyRotation(
|
||||
RotationWire(initiatorProof, PpapMessageType.RotationProof));
|
||||
var responderStart = initiator.ProcessKeyRotation(
|
||||
RotationWire(initiatorCommit, PpapMessageType.RotationCommit));
|
||||
|
||||
var responderOffer = responder.ProcessKeyRotation(
|
||||
RotationWire(responderStart, PpapMessageType.RotationStart));
|
||||
var responderChallenge = initiator.ProcessKeyRotation(
|
||||
RotationWire(responderOffer, PpapMessageType.RotationOffer));
|
||||
var responderProof = responder.ProcessKeyRotation(
|
||||
RotationWire(responderChallenge, PpapMessageType.RotationChallenge));
|
||||
var responderCommit = initiator.ProcessKeyRotation(
|
||||
RotationWire(responderProof, PpapMessageType.RotationProof));
|
||||
var acknowledgement = responder.ProcessKeyRotation(
|
||||
RotationWire(responderCommit, PpapMessageType.RotationCommit));
|
||||
var done = initiator.ProcessKeyRotation(
|
||||
RotationWire(acknowledgement, PpapMessageType.RotationCommitAck));
|
||||
var completed = responder.ProcessKeyRotation(
|
||||
RotationWire(done, PpapMessageType.RotationDone));
|
||||
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, done.Ruling);
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, completed.Ruling);
|
||||
}
|
||||
|
||||
static byte[] Wire(AuthenticationResult result, PpapMessageType expectedType)
|
||||
{
|
||||
var wire = Assert.IsType<byte[]>(result.AuthenticationData);
|
||||
Assert.True(wire.Length >= 10);
|
||||
Assert.Equal(expectedType, (PpapMessageType)wire[5]);
|
||||
return wire;
|
||||
}
|
||||
|
||||
static byte[] RotationWire(
|
||||
AuthenticationKeyRotationResult result,
|
||||
PpapMessageType expectedType)
|
||||
{
|
||||
var wire = Assert.IsType<byte[]>(result.Data);
|
||||
Assert.True(wire.Length >= 10);
|
||||
Assert.Equal(expectedType, (PpapMessageType)wire[5]);
|
||||
return wire;
|
||||
}
|
||||
|
||||
static byte[] TamperLastByte(byte[] wire)
|
||||
{
|
||||
var tampered = (byte[])wire.Clone();
|
||||
tampered[^1] ^= 0x80;
|
||||
return tampered;
|
||||
}
|
||||
|
||||
static byte[] TamperField(byte[] wire, int fieldIndex)
|
||||
{
|
||||
var tampered = (byte[])wire.Clone();
|
||||
var offset = 10;
|
||||
for (var index = 0; index <= fieldIndex; index++)
|
||||
{
|
||||
Assert.True(tampered.Length - offset >= sizeof(int));
|
||||
var length = BinaryPrimitives.ReadInt32BigEndian(
|
||||
tampered.AsSpan(offset, sizeof(int)));
|
||||
offset += sizeof(int);
|
||||
Assert.InRange(length, 1, tampered.Length - offset);
|
||||
|
||||
if (index == fieldIndex)
|
||||
{
|
||||
tampered[offset + (length / 2)] ^= 0x80;
|
||||
return tampered;
|
||||
}
|
||||
|
||||
offset += length;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("The requested PPAP field was not found.");
|
||||
}
|
||||
|
||||
static byte[] ExtractField(byte[] wire, int fieldIndex)
|
||||
{
|
||||
var offset = 10;
|
||||
for (var index = 0; index <= fieldIndex; index++)
|
||||
{
|
||||
Assert.True(wire.Length - offset >= sizeof(int));
|
||||
var length = BinaryPrimitives.ReadInt32BigEndian(
|
||||
wire.AsSpan(offset, sizeof(int)));
|
||||
offset += sizeof(int);
|
||||
Assert.InRange(length, 1, wire.Length - offset);
|
||||
|
||||
if (index == fieldIndex)
|
||||
return wire.AsSpan(offset, length).ToArray();
|
||||
|
||||
offset += length;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("The requested PPAP field was not found.");
|
||||
}
|
||||
|
||||
static void AssertDescriptorNotVisible(
|
||||
byte[] wire,
|
||||
PpapRegistrationRecord registration)
|
||||
{
|
||||
Assert.False(ContainsSubsequence(wire, registration.Nonce),
|
||||
"The PPAP proof contains the plaintext registration nonce.");
|
||||
Assert.False(ContainsSubsequence(
|
||||
wire,
|
||||
EncodeDescriptorVersionPrefix(registration)),
|
||||
"The PPAP proof contains the plaintext registration version descriptor.");
|
||||
Assert.False(ContainsSubsequence(
|
||||
wire,
|
||||
EncodeDescriptorProfile(registration.KdfProfile)),
|
||||
"The PPAP proof contains the plaintext registration KDF descriptor.");
|
||||
}
|
||||
|
||||
static byte[] EncodeDescriptorVersionPrefix(PpapRegistrationRecord registration)
|
||||
{
|
||||
var encoded = new byte[1 + sizeof(long) + sizeof(int)];
|
||||
encoded[0] = (byte)registration.Kind;
|
||||
BinaryPrimitives.WriteInt64BigEndian(
|
||||
encoded.AsSpan(1, sizeof(long)),
|
||||
registration.Version);
|
||||
BinaryPrimitives.WriteInt32BigEndian(
|
||||
encoded.AsSpan(1 + sizeof(long), sizeof(int)),
|
||||
registration.Nonce.Length);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
static byte[] EncodeDescriptorProfile(PpapKdfProfile profile)
|
||||
{
|
||||
Assert.NotNull(profile);
|
||||
var encoded = new byte[1 + (4 * sizeof(int))];
|
||||
encoded[0] = 1;
|
||||
BinaryPrimitives.WriteInt32BigEndian(
|
||||
encoded.AsSpan(1, sizeof(int)),
|
||||
profile.Version);
|
||||
BinaryPrimitives.WriteInt32BigEndian(
|
||||
encoded.AsSpan(1 + sizeof(int), sizeof(int)),
|
||||
profile.MemoryKiB);
|
||||
BinaryPrimitives.WriteInt32BigEndian(
|
||||
encoded.AsSpan(1 + (2 * sizeof(int)), sizeof(int)),
|
||||
profile.Iterations);
|
||||
BinaryPrimitives.WriteInt32BigEndian(
|
||||
encoded.AsSpan(1 + (3 * sizeof(int)), sizeof(int)),
|
||||
profile.Parallelism);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
static bool ContainsSubsequence(byte[] value, byte[] candidate)
|
||||
{
|
||||
if (candidate.Length == 0 || candidate.Length > value.Length)
|
||||
return false;
|
||||
|
||||
for (var offset = 0; offset <= value.Length - candidate.Length; offset++)
|
||||
{
|
||||
if (value.AsSpan(offset, candidate.Length).SequenceEqual(candidate))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static RegistrationSnapshot Snapshot(PpapRegistrationRecord record)
|
||||
=> new(record.Version, record.Nonce, record.EncapsulationKey);
|
||||
|
||||
static void AssertSnapshot(RegistrationSnapshot expected,
|
||||
PpapRegistrationRecord actual)
|
||||
{
|
||||
Assert.Equal(expected.Version, actual.Version);
|
||||
Assert.Equal(expected.Nonce, actual.Nonce);
|
||||
Assert.Equal(expected.EncapsulationKey, actual.EncapsulationKey);
|
||||
}
|
||||
|
||||
static void AssertRotated(RegistrationSnapshot before,
|
||||
PpapRegistrationRecord after)
|
||||
{
|
||||
Assert.Equal(before.Version + 1, after.Version);
|
||||
Assert.False(before.Nonce.SequenceEqual(after.Nonce));
|
||||
Assert.False(before.EncapsulationKey.SequenceEqual(after.EncapsulationKey));
|
||||
}
|
||||
|
||||
static PpapPair CreatePair(
|
||||
AuthenticationMode mode,
|
||||
string initiatorPassword = InitiatorPassword,
|
||||
string responderPassword = ResponderPassword)
|
||||
{
|
||||
var authenticatesInitiator = mode is AuthenticationMode.InitializerIdentity
|
||||
or AuthenticationMode.DualIdentity;
|
||||
var authenticatesResponder = mode is AuthenticationMode.ResponderIdentity
|
||||
or AuthenticationMode.DualIdentity;
|
||||
|
||||
var initiatorStore = new InMemoryPpapRegistrationStore();
|
||||
var responderStore = new InMemoryPpapRegistrationStore();
|
||||
PpapLocalIdentity? initiatorLocal = null;
|
||||
PpapLocalIdentity? responderLocal = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (authenticatesInitiator)
|
||||
{
|
||||
initiatorLocal = PpapLocalIdentity.FromPassword(
|
||||
InitiatorIdentity, initiatorPassword, TestKdf);
|
||||
using var registered = PpapLocalIdentity.FromPassword(
|
||||
InitiatorIdentity, InitiatorPassword, TestKdf);
|
||||
Assert.True(responderStore.TryAdd(
|
||||
Domain,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, registered)));
|
||||
}
|
||||
|
||||
if (authenticatesResponder)
|
||||
{
|
||||
responderLocal = PpapLocalIdentity.FromPassword(
|
||||
ResponderIdentity, responderPassword, TestKdf);
|
||||
using var registered = PpapLocalIdentity.FromPassword(
|
||||
ResponderIdentity, ResponderPassword, TestKdf);
|
||||
Assert.True(initiatorStore.TryAdd(
|
||||
Domain,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, registered)));
|
||||
}
|
||||
|
||||
var initiatorProvider = new PpapAuthenticationProvider(
|
||||
initiatorLocal!, initiatorStore);
|
||||
var responderProvider = new PpapAuthenticationProvider(
|
||||
responderLocal!, responderStore);
|
||||
var initiator = Assert.IsType<PpapAuthenticationHandler>(
|
||||
initiatorProvider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = AuthenticationDirection.Initiator,
|
||||
Mode = mode,
|
||||
Domain = Domain,
|
||||
InitiatorIdentity = authenticatesInitiator ? InitiatorIdentity : null,
|
||||
ResponderIdentity = authenticatesResponder ? ResponderIdentity : null,
|
||||
HostName = ResponderIdentity,
|
||||
}));
|
||||
var responder = Assert.IsType<PpapAuthenticationHandler>(
|
||||
responderProvider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = AuthenticationDirection.Responder,
|
||||
Mode = mode,
|
||||
Domain = Domain,
|
||||
InitiatorIdentity = authenticatesInitiator ? InitiatorIdentity : null,
|
||||
ResponderIdentity = authenticatesResponder ? ResponderIdentity : null,
|
||||
HostName = ResponderIdentity,
|
||||
}));
|
||||
|
||||
return new PpapPair(
|
||||
initiatorLocal,
|
||||
responderLocal,
|
||||
initiatorStore,
|
||||
responderStore,
|
||||
initiator,
|
||||
responder);
|
||||
}
|
||||
catch
|
||||
{
|
||||
initiatorLocal?.Dispose();
|
||||
responderLocal?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static PpapPair CreateStaticPair()
|
||||
{
|
||||
PpapCryptography.GenerateKeyPair(
|
||||
out var initiatorPrivateKey,
|
||||
out var initiatorPublicKey);
|
||||
PpapCryptography.GenerateKeyPair(
|
||||
out var responderPrivateKey,
|
||||
out var responderPublicKey);
|
||||
PpapLocalIdentity? initiatorLocal = null;
|
||||
PpapLocalIdentity? responderLocal = null;
|
||||
|
||||
try
|
||||
{
|
||||
initiatorLocal = PpapLocalIdentity.FromStaticKey(
|
||||
InitiatorIdentity,
|
||||
initiatorPrivateKey);
|
||||
responderLocal = PpapLocalIdentity.FromStaticKey(
|
||||
ResponderIdentity,
|
||||
responderPrivateKey);
|
||||
var initiatorStore = new InMemoryPpapRegistrationStore();
|
||||
var responderStore = new InMemoryPpapRegistrationStore();
|
||||
Assert.True(responderStore.TryAdd(
|
||||
Domain,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, initiatorLocal)));
|
||||
Assert.True(initiatorStore.TryAdd(
|
||||
Domain,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, responderLocal)));
|
||||
|
||||
var initiatorProvider = new PpapAuthenticationProvider(
|
||||
initiatorLocal!,
|
||||
initiatorStore);
|
||||
var responderProvider = new PpapAuthenticationProvider(
|
||||
responderLocal!,
|
||||
responderStore);
|
||||
var initiator = Assert.IsType<PpapAuthenticationHandler>(
|
||||
initiatorProvider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = AuthenticationDirection.Initiator,
|
||||
Mode = AuthenticationMode.DualIdentity,
|
||||
Domain = Domain,
|
||||
InitiatorIdentity = InitiatorIdentity,
|
||||
ResponderIdentity = ResponderIdentity,
|
||||
HostName = ResponderIdentity,
|
||||
}));
|
||||
var responder = Assert.IsType<PpapAuthenticationHandler>(
|
||||
responderProvider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = AuthenticationDirection.Responder,
|
||||
Mode = AuthenticationMode.DualIdentity,
|
||||
Domain = Domain,
|
||||
InitiatorIdentity = InitiatorIdentity,
|
||||
ResponderIdentity = ResponderIdentity,
|
||||
HostName = ResponderIdentity,
|
||||
}));
|
||||
|
||||
return new PpapPair(
|
||||
initiatorLocal,
|
||||
responderLocal,
|
||||
initiatorStore,
|
||||
responderStore,
|
||||
initiator,
|
||||
responder);
|
||||
}
|
||||
catch
|
||||
{
|
||||
initiatorLocal?.Dispose();
|
||||
responderLocal?.Dispose();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(initiatorPrivateKey);
|
||||
PpapCryptography.Clear(initiatorPublicKey);
|
||||
PpapCryptography.Clear(responderPrivateKey);
|
||||
PpapCryptography.Clear(responderPublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
readonly record struct RegistrationSnapshot(
|
||||
long Version,
|
||||
byte[] Nonce,
|
||||
byte[] EncapsulationKey);
|
||||
|
||||
readonly record struct HandshakeResult(
|
||||
AuthenticationResult Initiator,
|
||||
AuthenticationResult Responder);
|
||||
|
||||
sealed class PpapPair : IDisposable
|
||||
{
|
||||
readonly PpapLocalIdentity? _initiatorLocal;
|
||||
readonly PpapLocalIdentity? _responderLocal;
|
||||
|
||||
public InMemoryPpapRegistrationStore InitiatorStore { get; }
|
||||
public InMemoryPpapRegistrationStore ResponderStore { get; }
|
||||
public PpapAuthenticationHandler Initiator { get; }
|
||||
public PpapAuthenticationHandler Responder { get; }
|
||||
|
||||
public PpapPair(
|
||||
PpapLocalIdentity? initiatorLocal,
|
||||
PpapLocalIdentity? responderLocal,
|
||||
InMemoryPpapRegistrationStore initiatorStore,
|
||||
InMemoryPpapRegistrationStore responderStore,
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
_initiatorLocal = initiatorLocal;
|
||||
_responderLocal = responderLocal;
|
||||
InitiatorStore = initiatorStore;
|
||||
ResponderStore = responderStore;
|
||||
Initiator = initiator;
|
||||
Responder = responder;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Initiator.Dispose();
|
||||
Responder.Dispose();
|
||||
_initiatorLocal?.Dispose();
|
||||
_responderLocal?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class PpapRegistrationStoreTests
|
||||
{
|
||||
const string Domain = "example.test";
|
||||
const string Identity = "alice";
|
||||
|
||||
static readonly PpapKdfProfile TestKdf = new(
|
||||
PpapKdfProfile.Argon2Version13,
|
||||
memoryKiB: 8 * 1024,
|
||||
iterations: 1,
|
||||
parallelism: 1);
|
||||
|
||||
[Fact]
|
||||
public async Task TryRotate_ConcurrentCompareAndSwap_AllowsExactlyOneWinner()
|
||||
{
|
||||
var store = new InMemoryPpapRegistrationStore();
|
||||
var current = PpapRegistrationRecord.FromPassword(
|
||||
Domain, Identity, "correct horse battery staple", kdfProfile: TestKdf);
|
||||
Assert.True(store.TryAdd(Domain, current));
|
||||
|
||||
var candidate = Replacement(current, marker: 1);
|
||||
var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var attempts = Enumerable.Range(0, 16).Select(async _ =>
|
||||
{
|
||||
await start.Task;
|
||||
return store.TryRotate(Domain, Identity, current.Version, candidate);
|
||||
}).ToArray();
|
||||
|
||||
start.SetResult();
|
||||
var results = await Task.WhenAll(attempts);
|
||||
|
||||
Assert.Single(results, won => won);
|
||||
var stored = Assert.IsType<PpapRegistrationRecord>(store.Get(Domain, Identity));
|
||||
|
||||
Assert.Same(candidate, stored);
|
||||
Assert.Equal(current.Version + 1, stored.Version);
|
||||
Assert.Equal(candidate.Nonce, stored.Nonce);
|
||||
Assert.Equal(candidate.EncapsulationKey, stored.EncapsulationKey);
|
||||
Assert.False(current.Nonce.SequenceEqual(stored.Nonce));
|
||||
Assert.False(current.EncapsulationKey.SequenceEqual(stored.EncapsulationKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRotate_StaleVersionFailsWithoutChangingRecord()
|
||||
{
|
||||
var store = new InMemoryPpapRegistrationStore();
|
||||
var current = PpapRegistrationRecord.FromPassword(
|
||||
Domain, Identity, "correct horse battery staple", kdfProfile: TestKdf);
|
||||
var replacement = Replacement(current, marker: 99);
|
||||
Assert.True(store.TryAdd(Domain, current));
|
||||
|
||||
Assert.False(store.TryRotate(Domain, Identity, current.Version - 1, replacement));
|
||||
|
||||
var stored = Assert.IsType<PpapRegistrationRecord>(store.Get(Domain, Identity));
|
||||
Assert.Same(current, stored);
|
||||
Assert.Equal(current.Version, stored.Version);
|
||||
Assert.Equal(current.Nonce, stored.Nonce);
|
||||
Assert.Equal(current.EncapsulationKey, stored.EncapsulationKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRotate_RejectsVersionBumpWithoutFreshNonceAndKey()
|
||||
{
|
||||
var store = new InMemoryPpapRegistrationStore();
|
||||
var current = PpapRegistrationRecord.FromPassword(
|
||||
Domain, Identity, "correct horse battery staple", kdfProfile: TestKdf);
|
||||
var noOp = new PpapRegistrationRecord(
|
||||
current.Version + 1,
|
||||
current.Identity,
|
||||
current.Kind,
|
||||
current.Nonce,
|
||||
current.EncapsulationKey,
|
||||
current.KdfProfile);
|
||||
Assert.True(store.TryAdd(Domain, current));
|
||||
|
||||
Assert.False(store.TryRotate(Domain, Identity, current.Version, noOp));
|
||||
Assert.Same(current, store.Get(Domain, Identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRotate_RejectsChangedKdfProfile()
|
||||
{
|
||||
var store = new InMemoryPpapRegistrationStore();
|
||||
var current = PpapRegistrationRecord.FromPassword(
|
||||
Domain, Identity, "correct horse battery staple", kdfProfile: TestKdf);
|
||||
var changedKdf = new PpapKdfProfile(
|
||||
PpapKdfProfile.Argon2Version13,
|
||||
memoryKiB: 8 * 1024,
|
||||
iterations: 2,
|
||||
parallelism: 1);
|
||||
var replacement = Replacement(
|
||||
current,
|
||||
marker: 7,
|
||||
kdfProfile: changedKdf);
|
||||
Assert.True(store.TryAdd(Domain, current));
|
||||
|
||||
Assert.False(store.TryRotate(
|
||||
Domain,
|
||||
Identity,
|
||||
current.Version,
|
||||
replacement));
|
||||
Assert.Same(current, store.Get(Domain, Identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PasswordRegistration_IsDomainSeparated()
|
||||
{
|
||||
var nonce = Enumerable.Range(1, PpapProtocol.RegistrationNonceLength)
|
||||
.Select(value => (byte)value)
|
||||
.ToArray();
|
||||
|
||||
var first = PpapRegistrationRecord.FromPassword(
|
||||
"first.example",
|
||||
Identity,
|
||||
"correct horse battery staple",
|
||||
nonce: nonce,
|
||||
kdfProfile: TestKdf);
|
||||
var second = PpapRegistrationRecord.FromPassword(
|
||||
"second.example",
|
||||
Identity,
|
||||
"correct horse battery staple",
|
||||
nonce: nonce,
|
||||
kdfProfile: TestKdf);
|
||||
|
||||
Assert.Equal(first.Nonce, second.Nonce);
|
||||
Assert.False(first.EncapsulationKey.SequenceEqual(second.EncapsulationKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegistrationRecord_DoesNotExposeMutableKeyMaterial()
|
||||
{
|
||||
var record = PpapRegistrationRecord.FromPassword(
|
||||
Domain,
|
||||
Identity,
|
||||
"correct horse battery staple",
|
||||
kdfProfile: TestKdf);
|
||||
var originalNonce = record.Nonce;
|
||||
var originalKey = record.EncapsulationKey;
|
||||
|
||||
var exposedNonce = record.Nonce;
|
||||
var exposedKey = record.EncapsulationKey;
|
||||
exposedNonce[0] ^= 0xFF;
|
||||
exposedKey[0] ^= 0xFF;
|
||||
|
||||
Assert.Equal(originalNonce, record.Nonce);
|
||||
Assert.Equal(originalKey, record.EncapsulationKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportStaticPrivateKey_RoundTripsRegistrationAndReturnsDefensiveCopies()
|
||||
{
|
||||
using var original = PpapLocalIdentity.CreateStatic(Identity);
|
||||
var originalRegistration = PpapRegistrationRecord.FromLocalIdentity(
|
||||
Domain,
|
||||
original);
|
||||
|
||||
var firstExport = original.ExportStaticPrivateKey();
|
||||
var secondExport = original.ExportStaticPrivateKey();
|
||||
Assert.NotSame(firstExport, secondExport);
|
||||
Assert.Equal(firstExport, secondExport);
|
||||
|
||||
using var imported = PpapLocalIdentity.FromStaticKey(Identity, firstExport);
|
||||
var importedRegistration = PpapRegistrationRecord.FromLocalIdentity(
|
||||
Domain,
|
||||
imported);
|
||||
|
||||
Assert.Equal(originalRegistration.Identity, importedRegistration.Identity);
|
||||
Assert.Equal(originalRegistration.Kind, importedRegistration.Kind);
|
||||
Assert.Equal(originalRegistration.Nonce, importedRegistration.Nonce);
|
||||
Assert.Equal(
|
||||
originalRegistration.EncapsulationKey,
|
||||
importedRegistration.EncapsulationKey);
|
||||
|
||||
firstExport[0] ^= 0xFF;
|
||||
secondExport[1] ^= 0xFF;
|
||||
|
||||
Assert.Equal(
|
||||
originalRegistration.EncapsulationKey,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, original).EncapsulationKey);
|
||||
Assert.Equal(
|
||||
importedRegistration.EncapsulationKey,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, imported).EncapsulationKey);
|
||||
|
||||
var importedExport = imported.ExportStaticPrivateKey();
|
||||
Assert.NotSame(firstExport, importedExport);
|
||||
importedExport[2] ^= 0xFF;
|
||||
Assert.Equal(
|
||||
importedRegistration.EncapsulationKey,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, imported).EncapsulationKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PasswordDerivationLimiter_RejectsImmediatelyWhenSaturated()
|
||||
{
|
||||
var limiter = new PpapPasswordDerivationLimiter(1);
|
||||
using var identity = PpapLocalIdentity.FromPassword(
|
||||
Identity,
|
||||
"correct horse battery staple",
|
||||
TestKdf);
|
||||
var provider = new PpapAuthenticationProvider(
|
||||
identity,
|
||||
passwordDerivationLimiter: limiter);
|
||||
var nonce = Enumerable.Range(0, PpapProtocol.RegistrationNonceLength)
|
||||
.Select(value => (byte)(value + 1))
|
||||
.ToArray();
|
||||
|
||||
using var heldSlot = limiter.TryAcquire(postAuthentication: false);
|
||||
Assert.NotNull(heldSlot);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
provider.DerivePrivateKey(identity, Domain, nonce, TestKdf));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PasswordDerivationLimiter_ReservesPostAuthenticationCapacityAndReleases()
|
||||
{
|
||||
var limiter = new PpapPasswordDerivationLimiter(
|
||||
maximumConcurrency: 2,
|
||||
reservedPostAuthenticationSlots: 1);
|
||||
|
||||
var preAuthentication = limiter.TryAcquire(postAuthentication: false);
|
||||
Assert.NotNull(preAuthentication);
|
||||
Assert.Null(limiter.TryAcquire(postAuthentication: false));
|
||||
|
||||
using (var postAuthentication = limiter.TryAcquire(postAuthentication: true))
|
||||
Assert.NotNull(postAuthentication);
|
||||
|
||||
Assert.Null(limiter.TryAcquire(postAuthentication: false));
|
||||
preAuthentication.Dispose();
|
||||
using var reacquired = limiter.TryAcquire(postAuthentication: false);
|
||||
Assert.NotNull(reacquired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PasswordDerivationLimiter_AllowsPostAuthenticationDerivationInReservedSlot()
|
||||
{
|
||||
var limiter = new PpapPasswordDerivationLimiter(
|
||||
maximumConcurrency: 2,
|
||||
reservedPostAuthenticationSlots: 1);
|
||||
using var identity = PpapLocalIdentity.FromPassword(
|
||||
Identity,
|
||||
"correct horse battery staple",
|
||||
TestKdf);
|
||||
var provider = new PpapAuthenticationProvider(
|
||||
identity,
|
||||
passwordDerivationLimiter: limiter);
|
||||
var nonce = Enumerable.Range(0, PpapProtocol.RegistrationNonceLength)
|
||||
.Select(value => (byte)(value + 1))
|
||||
.ToArray();
|
||||
|
||||
using var occupiedPreAuthenticationSlot =
|
||||
limiter.TryAcquire(postAuthentication: false);
|
||||
Assert.NotNull(occupiedPreAuthenticationSlot);
|
||||
var privateKey = provider.DerivePrivateKey(
|
||||
identity,
|
||||
Domain,
|
||||
nonce,
|
||||
TestKdf,
|
||||
postAuthentication: true);
|
||||
try
|
||||
{
|
||||
Assert.Equal(PpapProtocol.PrivateKeyLength, privateKey.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
static PpapRegistrationRecord Replacement(
|
||||
PpapRegistrationRecord current,
|
||||
int marker,
|
||||
PpapKdfProfile? kdfProfile = null)
|
||||
{
|
||||
var nonce = current.Nonce;
|
||||
nonce[0] ^= (byte)marker;
|
||||
nonce[^1] ^= (byte)(marker * 17);
|
||||
PpapCryptography.GenerateKeyPair(out var privateKey, out var publicKey);
|
||||
try
|
||||
{
|
||||
return new PpapRegistrationRecord(
|
||||
current.Version + 1,
|
||||
current.Identity,
|
||||
current.Kind,
|
||||
nonce,
|
||||
publicKey,
|
||||
kdfProfile ?? current.KdfProfile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(privateKey);
|
||||
PpapCryptography.Clear(publicKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user