PPAP integration

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