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
@@ -15,6 +15,7 @@ namespace Esiur.Net.Packets
// Actions // Actions
Handshake = 0x80, Handshake = 0x80,
FinalHandshake = 0x81, FinalHandshake = 0x81,
KeyRotation = 0x82,
// Acks // Acks
Denied = 0x40, // no reason, terminate connection Denied = 0x40, // no reason, terminate connection
@@ -33,6 +34,7 @@ namespace Esiur.Net.Packets
ErrorRetry = 0xC3, ErrorRetry = 0xC3,
IndicationEstablished = 0xC8, IndicationEstablished = 0xC8,
KeyRotationEstablished = 0xC9,
IAuthPlain = 0xD0, IAuthPlain = 0xD0,
IAuthHashed = 0xD1, IAuthHashed = 0xD1,
@@ -1,249 +0,0 @@
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Prng;
using Org.BouncyCastle.Security;
using System;
using System.Collections.Generic;
using System.Text;
namespace Esiur.Net.Ppap
{
public class DeterministicGenerator
: SecureRandom
{
private static long counter = DateTime.UtcNow.Ticks;
public byte[] value;
private static long NextCounterValue()
{
return Interlocked.Increment(ref counter);
}
private static readonly SecureRandom MasterRandom = new SecureRandom(new CryptoApiRandomGenerator());
internal static readonly SecureRandom ArbitraryRandom = new SecureRandom(new VmpcRandomGenerator(), 16);
private static DigestRandomGenerator CreatePrng(string digestName, bool autoSeed)
{
IDigest digest = DigestUtilities.GetDigest(digestName);
if (digest == null)
return null;
DigestRandomGenerator prng = new DigestRandomGenerator(digest);
if (autoSeed)
{
AutoSeed(prng, 2 * digest.GetDigestSize());
}
return prng;
}
public static new byte[] GetNextBytes(SecureRandom secureRandom, int length)
{
byte[] result = new byte[length];
secureRandom.NextBytes(result);
return result;
}
public static new SecureRandom GetInstance(string algorithm)
{
return GetInstance(algorithm, true);
}
public static new SecureRandom GetInstance(string algorithm, bool autoSeed)
{
if (algorithm == null)
throw new ArgumentNullException(nameof(algorithm));
if (algorithm.EndsWith("PRNG", StringComparison.OrdinalIgnoreCase))
{
string digestName = algorithm.Substring(0, algorithm.Length - "PRNG".Length);
DigestRandomGenerator prng = CreatePrng(digestName, autoSeed);
if (prng != null)
return new SecureRandom(prng);
}
throw new ArgumentException("Unrecognised PRNG algorithm: " + algorithm, "algorithm");
}
protected new readonly IRandomGenerator generator;
public DeterministicGenerator(byte[] value)
: this(CreatePrng("SHA256", true))
{
this.value = value;
}
public DeterministicGenerator(IRandomGenerator generator)
{
this.generator = generator;
}
public DeterministicGenerator(IRandomGenerator generator, int autoSeedLengthInBytes)
{
AutoSeed(generator, autoSeedLengthInBytes);
this.generator = generator;
}
public override byte[] GenerateSeed(int length)
{
return GetNextBytes(MasterRandom, length);
}
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
public override void GenerateSeed(Span<byte> seed)
{
MasterRandom.NextBytes(seed);
}
#endif
public override void SetSeed(byte[] seed)
{
generator.AddSeedMaterial(seed);
}
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
public override void SetSeed(Span<byte> seed)
{
generator.AddSeedMaterial(seed);
}
#endif
public override void SetSeed(long seed)
{
generator.AddSeedMaterial(seed);
}
public override int Next()
{
return NextInt() & int.MaxValue;
}
public override int Next(int maxValue)
{
if (maxValue < 2)
{
if (maxValue < 0)
throw new ArgumentOutOfRangeException("maxValue", "cannot be negative");
return 0;
}
int bits;
// Test whether maxValue is a power of 2
if ((maxValue & (maxValue - 1)) == 0)
{
bits = NextInt() & int.MaxValue;
return (int)(((long)bits * maxValue) >> 31);
}
int result;
do
{
bits = NextInt() & int.MaxValue;
result = bits % maxValue;
}
while (bits - result + (maxValue - 1) < 0); // Ignore results near overflow
return result;
}
public override int Next(int minValue, int maxValue)
{
if (maxValue <= minValue)
{
if (maxValue == minValue)
return minValue;
throw new ArgumentException("maxValue cannot be less than minValue");
}
int diff = maxValue - minValue;
if (diff > 0)
return minValue + Next(diff);
for (; ; )
{
int i = NextInt();
if (i >= minValue && i < maxValue)
return i;
}
}
public override void NextBytes(byte[] buf)
{
Buffer.BlockCopy(value, 0, buf, 0, buf.Length);
}
public override void NextBytes(byte[] buf, int off, int len)
{
Buffer.BlockCopy(value, 0, buf, off, len);
}
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
public override void NextBytes(Span<byte> buffer)
{
if (generator != null)
{
generator.NextBytes(buffer);
}
else
{
byte[] tmp = new byte[buffer.Length];
NextBytes(tmp);
tmp.CopyTo(buffer);
}
}
#endif
private static readonly double DoubleScale = 1.0 / Convert.ToDouble(1L << 53);
public override double NextDouble()
{
ulong x = (ulong)NextLong() >> 11;
return Convert.ToDouble(x) * DoubleScale;
}
public override int NextInt()
{
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
Span<byte> bytes = stackalloc byte[4];
#else
byte[] bytes = new byte[4];
#endif
NextBytes(bytes);
return (int)0;
}
public override long NextLong()
{
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
Span<byte> bytes = stackalloc byte[8];
#else
byte[] bytes = new byte[8];
#endif
NextBytes(bytes);
return (long)0;
}
private static void AutoSeed(IRandomGenerator generator, int seedLength)
{
generator.AddSeedMaterial(NextCounterValue());
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
Span<byte> seed = seedLength <= 128
? stackalloc byte[seedLength]
: new byte[seedLength];
#else
byte[] seed = new byte[seedLength];
#endif
MasterRandom.NextBytes(seed);
generator.AddSeedMaterial(seed);
}
}
}
-61
View File
@@ -1,61 +0,0 @@
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using System;
using System.Collections.Generic;
using System.Text;
namespace Esiur.Net.Ppap
{
internal class KeyGenerator
{
public static (byte[], byte[]) Gen(MLKemParameters parameters = null)
{
var random = new RandomGenerator();
var keyGenParameters = new MLKemKeyGenerationParameters(random, parameters ?? MLKemParameters.ml_kem_768);
var kyberKeyPairGenerator = new MLKemKeyPairGenerator();
kyberKeyPairGenerator.Init(keyGenParameters);
var keys = kyberKeyPairGenerator.GenerateKeyPair();
return ((keys.Private as MLKemPrivateKeyParameters).GetEncoded(),
(keys.Public as MLKemPublicKeyParameters).GetEncoded());
}
public static (byte[], byte[]) GenS(byte[] username, byte[] password, byte[] registrationNonce, Argon2Parameters argon2parameters = null, MLKemParameters mlkemParameters = null)
{
var secret = new byte[username.Length + password.Length + registrationNonce.Length];
Buffer.BlockCopy(username, 0, secret, 0, username.Length);
Buffer.BlockCopy(password, 0, secret, username.Length, password.Length);
Buffer.BlockCopy(registrationNonce, 0, secret, username.Length + password.Length, registrationNonce.Length);
var output = new byte[64];
//Argon2id.DeriveKey(output, secret, registrationNonce, ArgonIterations, ArgonMemory * 1024);
var argon2 = new Argon2BytesGenerator();
var argon2params = argon2parameters ?? new Argon2Parameters.Builder(Argon2Parameters.Argon2id)
.WithSalt(registrationNonce)
.WithMemoryAsKB(1024 * 10)
.WithIterations(3)
.WithParallelism(1)
.Build();
argon2.Init(argon2params);
var output2 = new byte[64];
argon2.GenerateBytes(secret, output);
var random = new DeterministicGenerator(output);
var keyGenParameters = new MLKemKeyGenerationParameters(random, mlkemParameters ?? MLKemParameters.ml_kem_768);
var kyberKeyPairGenerator = new MLKemKeyPairGenerator();
kyberKeyPairGenerator.Init(keyGenParameters);
var keys = kyberKeyPairGenerator.GenerateKeyPair();
return ((keys.Private as MLKemPrivateKeyParameters).GetEncoded(),
(keys.Public as MLKemPublicKeyParameters).GetEncoded());
}
}
}
-247
View File
@@ -1,247 +0,0 @@
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Prng;
using Org.BouncyCastle.Security;
using System;
using System.Collections.Generic;
using System.Text;
namespace Esiur.Net.Ppap
{
public class RandomGenerator
: SecureRandom
{
private static long counter = DateTime.UtcNow.Ticks;
private static long NextCounterValue()
{
return Interlocked.Increment(ref counter);
}
private static readonly SecureRandom MasterRandom = new SecureRandom(new CryptoApiRandomGenerator());
internal static readonly SecureRandom ArbitraryRandom = new SecureRandom(new VmpcRandomGenerator(), 16);
private static DigestRandomGenerator CreatePrng(string digestName, bool autoSeed)
{
IDigest digest = DigestUtilities.GetDigest(digestName);
if (digest == null)
return null;
DigestRandomGenerator prng = new DigestRandomGenerator(digest);
if (autoSeed)
{
AutoSeed(prng, 2 * digest.GetDigestSize());
}
return prng;
}
public static new byte[] GetNextBytes(SecureRandom secureRandom, int length)
{
byte[] result = new byte[length];
secureRandom.NextBytes(result);
return result;
}
public static new SecureRandom GetInstance(string algorithm)
{
return GetInstance(algorithm, true);
}
public static new SecureRandom GetInstance(string algorithm, bool autoSeed)
{
if (algorithm == null)
throw new ArgumentNullException(nameof(algorithm));
if (algorithm.EndsWith("PRNG", StringComparison.OrdinalIgnoreCase))
{
string digestName = algorithm.Substring(0, algorithm.Length - "PRNG".Length);
DigestRandomGenerator prng = CreatePrng(digestName, autoSeed);
if (prng != null)
return new SecureRandom(prng);
}
throw new ArgumentException("Unrecognised PRNG algorithm: " + algorithm, "algorithm");
}
protected new readonly IRandomGenerator generator;
public RandomGenerator()
: this(CreatePrng("SHA256", true))
{
}
public RandomGenerator(IRandomGenerator generator)
{
this.generator = generator;
}
public RandomGenerator(IRandomGenerator generator, int autoSeedLengthInBytes)
{
AutoSeed(generator, autoSeedLengthInBytes);
this.generator = generator;
}
public override byte[] GenerateSeed(int length)
{
return GetNextBytes(MasterRandom, length);
}
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
public override void GenerateSeed(Span<byte> seed)
{
MasterRandom.NextBytes(seed);
}
#endif
public override void SetSeed(byte[] seed)
{
generator.AddSeedMaterial(seed);
}
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
public override void SetSeed(Span<byte> seed)
{
generator.AddSeedMaterial(seed);
}
#endif
public override void SetSeed(long seed)
{
generator.AddSeedMaterial(seed);
}
public override int Next()
{
return NextInt() & int.MaxValue;
}
public override int Next(int maxValue)
{
if (maxValue < 2)
{
if (maxValue < 0)
throw new ArgumentOutOfRangeException("maxValue", "cannot be negative");
return 0;
}
int bits;
// Test whether maxValue is a power of 2
if ((maxValue & (maxValue - 1)) == 0)
{
bits = NextInt() & int.MaxValue;
return (int)(((long)bits * maxValue) >> 31);
}
int result;
do
{
bits = NextInt() & int.MaxValue;
result = bits % maxValue;
}
while (bits - result + (maxValue - 1) < 0); // Ignore results near overflow
return result;
}
public override int Next(int minValue, int maxValue)
{
if (maxValue <= minValue)
{
if (maxValue == minValue)
return minValue;
throw new ArgumentException("maxValue cannot be less than minValue");
}
int diff = maxValue - minValue;
if (diff > 0)
return minValue + Next(diff);
for (; ; )
{
int i = NextInt();
if (i >= minValue && i < maxValue)
return i;
}
}
public override void NextBytes(byte[] buf)
{
generator.NextBytes(buf);
}
public override void NextBytes(byte[] buf, int off, int len)
{
generator.NextBytes(buf, off, len);
}
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
public override void NextBytes(Span<byte> buffer)
{
if (generator != null)
{
generator.NextBytes(buffer);
}
else
{
byte[] tmp = new byte[buffer.Length];
NextBytes(tmp);
tmp.CopyTo(buffer);
}
}
#endif
private static readonly double DoubleScale = 1.0 / Convert.ToDouble(1L << 53);
public override double NextDouble()
{
ulong x = (ulong)NextLong() >> 11;
return Convert.ToDouble(x) * DoubleScale;
}
public override int NextInt()
{
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
Span<byte> bytes = stackalloc byte[4];
#else
byte[] bytes = new byte[4];
#endif
NextBytes(bytes);
return (int)0;
}
public override long NextLong()
{
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
Span<byte> bytes = stackalloc byte[8];
#else
byte[] bytes = new byte[8];
#endif
NextBytes(bytes);
return (long)0;
}
private static void AutoSeed(IRandomGenerator generator, int seedLength)
{
generator.AddSeedMaterial(NextCounterValue());
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
Span<byte> seed = seedLength <= 128
? stackalloc byte[seedLength]
: new byte[seedLength];
#else
byte[] seed = new byte[seedLength];
#endif
MasterRandom.NextBytes(seed);
generator.AddSeedMaterial(seed);
}
}
}
+564 -42
View File
@@ -117,6 +117,16 @@ public partial class EpConnection : NetworkConnection, IStore
AsyncReply<bool> _openReply; AsyncReply<bool> _openReply;
bool _authenticated; bool _authenticated;
bool _authenticationHandshakeSucceeded;
bool _authenticationKeyRotationStarted;
bool _authenticationKeyRotationSucceeded;
IAuthenticationProvider _authenticationProvider;
AuthenticationContext _authenticationContext;
IAuthenticationHandler _disposedAuthenticationHandler;
readonly object _authenticationLifecycleLock = new object();
CancellationTokenSource _authenticationDeadlineCancellation;
long _authenticationAttemptGeneration;
string _hostname; string _hostname;
ushort _port; ushort _port;
@@ -360,7 +370,10 @@ public partial class EpConnection : NetworkConnection, IStore
if (socket.State == SocketState.Established && if (socket.State == SocketState.Established &&
_authDirection == AuthenticationDirection.Initiator) _authDirection == AuthenticationDirection.Initiator)
{ {
Declare(); // Outbound sockets normally finish connecting before they are assigned a
// receiver, so no NetworkConnect callback is delivered. Enter the same
// lifecycle path explicitly to start the deadline and generation guard.
Connected();
} }
} }
@@ -369,6 +382,13 @@ public partial class EpConnection : NetworkConnection, IStore
if (_authDirection != AuthenticationDirection.Initiator) if (_authDirection != AuthenticationDirection.Initiator)
return; return;
if (RequiresAuthenticationKeyRotation()
&& _session.EncryptionMode == EncryptionMode.None)
{
RejectEncryption("Authentication key rotation requires encrypted transport protection.");
return;
}
if (_session.EncryptionMode != EncryptionMode.None) if (_session.EncryptionMode != EncryptionMode.None)
{ {
try try
@@ -401,7 +421,7 @@ public partial class EpConnection : NetworkConnection, IStore
if (initAuthResult.Ruling == AuthenticationRuling.Succeeded) if (initAuthResult.Ruling == AuthenticationRuling.Succeeded)
{ {
SetSessionKey(initAuthResult.SessionKey); AdoptSessionKey(initAuthResult);
_session.LocalIdentity = initAuthResult.LocalIdentity; _session.LocalIdentity = initAuthResult.LocalIdentity;
_session.RemoteIdentity = initAuthResult.RemoteIdentity; _session.RemoteIdentity = initAuthResult.RemoteIdentity;
} }
@@ -535,6 +555,173 @@ public partial class EpConnection : NetworkConnection, IStore
return false; return false;
} }
bool RequiresAuthenticationKeyRotation()
=> _session?.AuthenticationHandler is IAuthenticationKeyRotationHandler handler
&& handler.RequiresKeyRotation;
void MarkAuthenticationHandshakeSucceeded()
{
_authenticationHandshakeSucceeded = true;
// A required protected post-authentication exchange is part of session
// authentication, not an optional follow-up. Keep the public session state
// false until that exchange has committed and readiness is published.
_session.Authenticated = !RequiresAuthenticationKeyRotation();
}
void BeginAuthenticationKeyRotation()
{
if (_authDirection != AuthenticationDirection.Initiator)
{
FailAuthenticationKeyRotation("Only the connection initiator can begin authentication key rotation.");
return;
}
if (_session?.AuthenticationHandler is not IAuthenticationKeyRotationHandler handler
|| !handler.RequiresKeyRotation)
{
FailAuthenticationKeyRotation("The authentication handler does not support required key rotation.");
return;
}
if (_session.EncryptionMode == EncryptionMode.None || !_session.EncryptionActive)
{
FailAuthenticationKeyRotation("Authentication key rotation requires active encrypted transport protection.");
return;
}
if (_authenticationKeyRotationStarted)
{
FailAuthenticationKeyRotation("Authentication key rotation was started more than once.");
return;
}
_authenticationKeyRotationStarted = true;
AuthenticationKeyRotationResult result;
try
{
result = handler.BeginKeyRotation();
}
catch (Exception ex)
{
FailAuthenticationKeyRotation(ex.Message);
return;
}
HandleAuthenticationKeyRotationResult(result);
}
void ProcessAuthenticationKeyRotation(object data)
{
if (_session?.AuthenticationHandler is not IAuthenticationKeyRotationHandler handler
|| !handler.RequiresKeyRotation)
{
FailAuthenticationKeyRotation("Unexpected authentication key-rotation message.");
return;
}
if (_session.EncryptionMode == EncryptionMode.None || !_session.EncryptionActive)
{
FailAuthenticationKeyRotation("Authentication key rotation was received outside encrypted transport protection.");
return;
}
if (_authDirection == AuthenticationDirection.Initiator
&& !_authenticationKeyRotationStarted)
{
FailAuthenticationKeyRotation("Authentication key rotation was not initiated locally.");
return;
}
_authenticationKeyRotationStarted = true;
AuthenticationKeyRotationResult result;
try
{
result = handler.ProcessKeyRotation(data);
}
catch (Exception ex)
{
FailAuthenticationKeyRotation(ex.Message);
return;
}
HandleAuthenticationKeyRotationResult(result);
}
void HandleAuthenticationKeyRotationResult(AuthenticationKeyRotationResult result)
{
if (result == null)
{
FailAuthenticationKeyRotation("The authentication key-rotation handler returned no result.");
return;
}
if (result.Ruling == AuthenticationKeyRotationRuling.Failed)
{
FailAuthenticationKeyRotation(result.Error);
return;
}
if (result.Ruling == AuthenticationKeyRotationRuling.InProgress)
{
_authenticationKeyRotationSucceeded = false;
SendAuthData(EpAuthPacketMethod.KeyRotation, result.Data);
return;
}
if (result.Ruling != AuthenticationKeyRotationRuling.Succeeded)
{
FailAuthenticationKeyRotation("The authentication key-rotation handler returned an invalid ruling.");
return;
}
_authenticationKeyRotationSucceeded = true;
if (_authDirection == AuthenticationDirection.Initiator)
{
// A final action is required even when it carries no data. It tells the
// responder to commit its side of the rotation and acknowledge readiness.
SendAuthData(EpAuthPacketMethod.KeyRotation, result.Data);
}
else
{
AuthenticatonCompleted(() =>
SendAuthData(EpAuthPacketMethod.KeyRotationEstablished, result.Data));
}
}
void FailAuthenticationKeyRotation(string error)
{
var localMessage = string.IsNullOrWhiteSpace(error)
? "Authentication key rotation failed."
: error;
const string peerMessage = "Authentication key rotation failed.";
// Handler failures can contain store, account, or concurrency details. Keep
// those details in local diagnostics while returning a uniform error to the
// peer so this trust-boundary failure cannot become an information oracle.
Global.Log("EpConnection:AuthenticationKeyRotation", LogType.Warning, localMessage);
_invalidCredentials = true;
_authenticationHandshakeSucceeded = false;
if (_session != null)
_session.Authenticated = false;
try
{
SendAuthMessage(EpAuthPacketMethod.ErrorTerminate, peerMessage);
}
catch (Exception ex)
{
Global.Log("EpConnection:AuthenticationKeyRotation", LogType.Warning, ex.Message);
}
FailPendingOpen(new AsyncException(ErrorType.Management, 0, localMessage));
Task.Delay(100).ContinueWith(_ => Close());
}
void PrepareSessionEncryption() void PrepareSessionEncryption()
{ {
if (_session.EncryptionMode == EncryptionMode.None || _session.SymetricCipher != null) if (_session.EncryptionMode == EncryptionMode.None || _session.SymetricCipher != null)
@@ -636,6 +823,21 @@ public partial class EpConnection : NetworkConnection, IStore
_session.Key = replacement; _session.Key = replacement;
} }
void AdoptSessionKey(AuthenticationResult result)
{
if (result == null)
throw new ArgumentNullException(nameof(result));
try
{
SetSessionKey(result.SessionKey);
}
finally
{
result.ClearSessionKey();
}
}
void CompletePendingOpen() void CompletePendingOpen()
{ {
var pending = Interlocked.Exchange(ref _openReply, null); var pending = Interlocked.Exchange(ref _openReply, null);
@@ -675,11 +877,185 @@ public partial class EpConnection : NetworkConnection, IStore
_outboundProtectionState = OutboundProtectionState.Plaintext; _outboundProtectionState = OutboundProtectionState.Plaintext;
_decryptInbound = false; _decryptInbound = false;
_decryptedReceiveBuffer = new NetworkBuffer(); _decryptedReceiveBuffer = new NetworkBuffer();
_authenticationHandshakeSucceeded = false;
_authenticationKeyRotationStarted = false;
_authenticationKeyRotationSucceeded = false;
if (_session != null) if (_session != null)
_session.EncryptionActive = false; _session.EncryptionActive = false;
} }
} }
void DisposeAuthenticationHandler()
{
var handler = _session?.AuthenticationHandler;
if (handler == null || ReferenceEquals(handler, _disposedAuthenticationHandler)
|| handler is not IDisposable disposable)
return;
_disposedAuthenticationHandler = handler;
try
{
disposable.Dispose();
}
catch (Exception ex)
{
Global.Log("EpConnection:AuthenticationHandler", LogType.Warning, ex.Message);
}
}
internal void RestartAuthenticationDeadline()
{
CancellationTokenSource previous;
CancellationTokenSource cancellation = null;
long generation = 0;
TimeSpan timeout = default;
lock (_authenticationLifecycleLock)
{
previous = _authenticationDeadlineCancellation;
_authenticationDeadlineCancellation = null;
if (AuthenticationTimeout > TimeSpan.Zero && !_authenticated)
{
cancellation = new CancellationTokenSource();
_authenticationDeadlineCancellation = cancellation;
generation = Volatile.Read(ref _authenticationAttemptGeneration);
timeout = AuthenticationTimeout;
}
}
CancelAndDispose(previous);
if (cancellation != null)
_ = EnforceAuthenticationDeadlineAsync(cancellation, generation, timeout);
}
async Task EnforceAuthenticationDeadlineAsync(
CancellationTokenSource cancellation,
long generation,
TimeSpan timeout)
{
try
{
await Task.Delay(timeout, cancellation.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
var ownsCancellation = false;
var timedOut = false;
lock (_authenticationLifecycleLock)
{
if (ReferenceEquals(_authenticationDeadlineCancellation, cancellation))
{
ownsCancellation = true;
_authenticationDeadlineCancellation = null;
timedOut = generation == Volatile.Read(ref _authenticationAttemptGeneration)
&& !_authenticated;
// Winning the deadline makes this attempt terminal before the lock is
// released. A delayed Warehouse callback can no longer publish it ready.
if (timedOut)
Interlocked.Increment(ref _authenticationAttemptGeneration);
}
}
// Only the path that atomically removed the source owns disposal. Restart
// and cancellation paths dispose sources they removed themselves.
if (ownsCancellation)
cancellation.Dispose();
if (!timedOut)
return;
const string message = "Authentication did not complete before the configured deadline.";
Global.Log("EpConnection:AuthenticationTimeout", LogType.Warning, message);
FailPendingOpen(new AsyncException(ErrorType.Management, 0, message));
Close();
}
void CancelAuthenticationDeadline()
{
CancellationTokenSource cancellation;
lock (_authenticationLifecycleLock)
{
cancellation = _authenticationDeadlineCancellation;
_authenticationDeadlineCancellation = null;
}
CancelAndDispose(cancellation);
}
void EndAuthenticationAttempt()
{
CancellationTokenSource cancellation;
lock (_authenticationLifecycleLock)
{
cancellation = _authenticationDeadlineCancellation;
_authenticationDeadlineCancellation = null;
Interlocked.Increment(ref _authenticationAttemptGeneration);
}
CancelAndDispose(cancellation);
}
static void CancelAndDispose(CancellationTokenSource cancellation)
{
if (cancellation == null)
return;
try
{
cancellation.Cancel();
}
finally
{
cancellation.Dispose();
}
}
bool TryPublishAuthenticationReady(
long generation,
Action beforeReady,
out Exception publicationError)
{
publicationError = null;
lock (_authenticationLifecycleLock)
{
if (generation != Volatile.Read(ref _authenticationAttemptGeneration)
|| !IsConnected
|| _authenticated)
return false;
try
{
// The protected completion frame must be queued before application
// traffic is accepted. Holding the lifecycle lock also arbitrates it
// atomically against the authentication deadline.
beforeReady?.Invoke();
}
catch (Exception ex)
{
publicationError = ex;
Interlocked.Increment(ref _authenticationAttemptGeneration);
return false;
}
// Some sockets report closure synchronously from Send. The lifecycle lock
// is re-entrant, so that callback may have invalidated this generation.
if (generation != Volatile.Read(ref _authenticationAttemptGeneration)
|| !IsConnected)
return false;
_session.Authenticated = true;
_authenticationHandshakeSucceeded = false;
_authenticated = true;
return true;
}
}
void DisposeSessionEncryption() void DisposeSessionEncryption()
{ {
lock (_encryptionSendLock) lock (_encryptionSendLock)
@@ -849,10 +1225,18 @@ public partial class EpConnection : NetworkConnection, IStore
public uint KeepAliveInterval { get; set; } = 30; public uint KeepAliveInterval { get; set; } = 30;
/// <summary>
/// Maximum time allowed for authentication, encryption setup, and any required
/// protected key rotation. A non-positive value disables the deadline.
/// </summary>
public TimeSpan AuthenticationTimeout { get; set; } = TimeSpan.FromSeconds(30);
public override void Destroy() public override void Destroy()
{ {
TerminateInvocations(); TerminateInvocations();
UnsubscribeAll(); UnsubscribeAll();
EndAuthenticationAttempt();
DisposeAuthenticationHandler();
DisposeSessionEncryption(); DisposeSessionEncryption();
this.OnReady = null; this.OnReady = null;
this.OnError = null; this.OnError = null;
@@ -1240,6 +1624,13 @@ public partial class EpConnection : NetworkConnection, IStore
// set auth handler for the session // set auth handler for the session
_session.AuthenticationHandler = handler; _session.AuthenticationHandler = handler;
if (RequiresAuthenticationKeyRotation()
&& _session.EncryptionMode == EncryptionMode.None)
{
RejectEncryption("Authentication key rotation requires encrypted transport protection.");
return offset;
}
var authResult = handler.Process(remoteAuthData); var authResult = handler.Process(remoteAuthData);
@@ -1260,10 +1651,10 @@ public partial class EpConnection : NetworkConnection, IStore
} }
else if (authResult.Ruling == AuthenticationRuling.Succeeded) else if (authResult.Ruling == AuthenticationRuling.Succeeded)
{ {
_session.Authenticated = true; MarkAuthenticationHandshakeSucceeded();
_session.LocalIdentity = authResult.LocalIdentity; _session.LocalIdentity = authResult.LocalIdentity;
_session.RemoteIdentity = authResult.RemoteIdentity; _session.RemoteIdentity = authResult.RemoteIdentity;
SetSessionKey(authResult.SessionKey); AdoptSessionKey(authResult);
try try
{ {
@@ -1275,7 +1666,7 @@ public partial class EpConnection : NetworkConnection, IStore
return offset; return offset;
} }
AuthenticatonCompleted(() => Action establishSession = () =>
{ {
// The initiator needs the selected provider and responder nonce // The initiator needs the selected provider and responder nonce
// before it can construct its cipher, so this acknowledgement is // before it can construct its cipher, so this acknowledgement is
@@ -1288,7 +1679,12 @@ public partial class EpConnection : NetworkConnection, IStore
} }
else else
SendAuthHeaders(EpAuthPacketMethod.SessionEstablished, localHeaders); SendAuthHeaders(EpAuthPacketMethod.SessionEstablished, localHeaders);
}); };
if (RequiresAuthenticationKeyRotation())
establishSession();
else
AuthenticatonCompleted(establishSession);
} }
} }
else if (_authPacket.Command == EpAuthPacketCommand.Acknowledge) else if (_authPacket.Command == EpAuthPacketCommand.Acknowledge)
@@ -1341,12 +1737,12 @@ public partial class EpConnection : NetworkConnection, IStore
return offset; return offset;
} }
SetSessionKey(authResult.SessionKey); AdoptSessionKey(authResult);
_session.LocalIdentity = authResult.LocalIdentity; _session.LocalIdentity = authResult.LocalIdentity;
_session.RemoteIdentity = authResult.RemoteIdentity; _session.RemoteIdentity = authResult.RemoteIdentity;
} }
_session.Authenticated = true; MarkAuthenticationHandshakeSucceeded();
try try
{ {
@@ -1428,14 +1824,22 @@ public partial class EpConnection : NetworkConnection, IStore
} }
else if (authResult.Ruling == AuthenticationRuling.Succeeded) else if (authResult.Ruling == AuthenticationRuling.Succeeded)
{ {
_session.Authenticated = true; MarkAuthenticationHandshakeSucceeded();
SetSessionKey(authResult.SessionKey); AdoptSessionKey(authResult);
_session.LocalIdentity = authResult.LocalIdentity; _session.LocalIdentity = authResult.LocalIdentity;
_session.RemoteIdentity = authResult.RemoteIdentity; _session.RemoteIdentity = authResult.RemoteIdentity;
try try
{ {
PrepareSessionEncryption(); PrepareSessionEncryption();
// The final handshake is the initiator's last plaintext
// packet. Arm inbound protection first so a fast responder
// cannot race its encrypted Established response ahead of
// this state transition. Outbound protection remains
// plaintext until Established is received.
if (_session.EncryptionMode != EncryptionMode.None)
EnableInboundEncryption();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1447,9 +1851,6 @@ public partial class EpConnection : NetworkConnection, IStore
SendAuthData(EpAuthPacketMethod.FinalHandshake, SendAuthData(EpAuthPacketMethod.FinalHandshake,
authResult.AuthenticationData); authResult.AuthenticationData);
if (_session.EncryptionMode != EncryptionMode.None)
EnableInboundEncryption();
//if (_authPacket.Method == EpAuthPacketMethod.SessionEstablished) //if (_authPacket.Method == EpAuthPacketMethod.SessionEstablished)
//{ //{
// AuthenticatonCompleted(authResult.LocalIdentity, authResult.RemoteIdentity); // AuthenticatonCompleted(authResult.LocalIdentity, authResult.RemoteIdentity);
@@ -1470,7 +1871,7 @@ public partial class EpConnection : NetworkConnection, IStore
var errorMessage = "Authentication error."; var errorMessage = "Authentication error.";
if (_authPacket.Tdu != null) if (_authPacket.Tdu != null)
{ {
var parsed = Codec.ParseSync(_authPacket.Tdu.Value, _serverWarehouse); var parsed = Codec.ParseSync(_authPacket.Tdu.Value, ParsingWarehouse);
if (parsed is string parsedErrorMsg) if (parsed is string parsedErrorMsg)
errorMessage = parsedErrorMsg; errorMessage = parsedErrorMsg;
} }
@@ -1491,7 +1892,7 @@ public partial class EpConnection : NetworkConnection, IStore
if (_authPacket.Tdu != null) if (_authPacket.Tdu != null)
{ {
var parsed = Codec.ParseSync(_authPacket.Tdu.Value, _serverWarehouse); var parsed = Codec.ParseSync(_authPacket.Tdu.Value, ParsingWarehouse);
authData = parsed; authData = parsed;
} }
@@ -1512,8 +1913,8 @@ public partial class EpConnection : NetworkConnection, IStore
} }
else if (authResult.Ruling == AuthenticationRuling.Succeeded) else if (authResult.Ruling == AuthenticationRuling.Succeeded)
{ {
_session.Authenticated = true; MarkAuthenticationHandshakeSucceeded();
SetSessionKey(authResult.SessionKey); AdoptSessionKey(authResult);
_session.LocalIdentity = authResult.LocalIdentity; _session.LocalIdentity = authResult.LocalIdentity;
_session.RemoteIdentity = authResult.RemoteIdentity; _session.RemoteIdentity = authResult.RemoteIdentity;
@@ -1530,9 +1931,7 @@ public partial class EpConnection : NetworkConnection, IStore
return offset; return offset;
} }
// Registration and receive readiness must complete before the Action establishSession = () =>
// initiator is allowed to send its first application request.
AuthenticatonCompleted(() =>
{ {
if (_session.EncryptionMode != EncryptionMode.None) if (_session.EncryptionMode != EncryptionMode.None)
{ {
@@ -1554,7 +1953,41 @@ public partial class EpConnection : NetworkConnection, IStore
authResult.AuthenticationData); authResult.AuthenticationData);
SendAuth(EpAuthPacketMethod.Established); SendAuth(EpAuthPacketMethod.Established);
} }
}); };
if (RequiresAuthenticationKeyRotation())
establishSession();
else
{
// Registration and receive readiness must complete before the
// initiator is allowed to send its first application request.
AuthenticatonCompleted(establishSession);
}
}
else if (_authDirection == AuthenticationDirection.Initiator)
{
try
{
PrepareSessionEncryption();
// An arbitrary-round handler may reach success on an
// Action packet rather than an Acknowledge packet. This
// final action is still the last plaintext initiator
// packet, so inbound decryption must be armed before it
// is sent to avoid racing the protected response.
if (_session.EncryptionMode != EncryptionMode.None)
EnableInboundEncryption();
}
catch (Exception ex)
{
RejectEncryption(ex.Message);
return offset;
}
// Send the terminal action even when it carries no data;
// it is the responder's signal to finalize authentication.
SendAuthData(EpAuthPacketMethod.FinalHandshake,
authResult.AuthenticationData);
} }
else if (authResult.AuthenticationData != null) else if (authResult.AuthenticationData != null)
{ {
@@ -1564,6 +1997,10 @@ public partial class EpConnection : NetworkConnection, IStore
} }
} }
else if (_authPacket.Method == EpAuthPacketMethod.KeyRotation)
{
ProcessAuthenticationKeyRotation(authData);
}
} }
else if (_authPacket.Command == EpAuthPacketCommand.Event) else if (_authPacket.Command == EpAuthPacketCommand.Event)
{ {
@@ -1574,7 +2011,7 @@ public partial class EpConnection : NetworkConnection, IStore
var errorMessage = "Authentication error."; var errorMessage = "Authentication error.";
if (_authPacket.Tdu != null) if (_authPacket.Tdu != null)
{ {
var parsed = Codec.ParseSync(_authPacket.Tdu.Value, _serverWarehouse); var parsed = Codec.ParseSync(_authPacket.Tdu.Value, ParsingWarehouse);
if (parsed is string parsedErrorMsg) if (parsed is string parsedErrorMsg)
errorMessage = parsedErrorMsg; errorMessage = parsedErrorMsg;
} }
@@ -1590,12 +2027,15 @@ public partial class EpConnection : NetworkConnection, IStore
} }
else if (_authPacket.Method == EpAuthPacketMethod.Established) else if (_authPacket.Method == EpAuthPacketMethod.Established)
{ {
if (_session.Authenticated) if (_authenticationHandshakeSucceeded)
{ {
if (_session.EncryptionMode != EncryptionMode.None) if (_session.EncryptionMode != EncryptionMode.None)
EnableEncryption(); EnableEncryption();
AuthenticatonCompleted(); if (RequiresAuthenticationKeyRotation())
BeginAuthenticationKeyRotation();
else
AuthenticatonCompleted();
} }
else else
{ {
@@ -1605,6 +2045,23 @@ public partial class EpConnection : NetworkConnection, IStore
Task.Delay(100).ContinueWith(x => Close()); Task.Delay(100).ContinueWith(x => Close());
} }
} }
else if (_authPacket.Method == EpAuthPacketMethod.KeyRotationEstablished)
{
if (_authDirection != AuthenticationDirection.Initiator
|| !RequiresAuthenticationKeyRotation()
|| _session.EncryptionMode == EncryptionMode.None
|| !_session.EncryptionActive
|| !_authenticationKeyRotationStarted
|| !_authenticationKeyRotationSucceeded)
{
FailAuthenticationKeyRotation(
"Unexpected authentication key-rotation completion event.");
}
else
{
AuthenticatonCompleted();
}
}
else if (_authPacket.Method == EpAuthPacketMethod.IndicationEstablished) else if (_authPacket.Method == EpAuthPacketMethod.IndicationEstablished)
{ {
// @TODO: handle multi-factor authentication indication // @TODO: handle multi-factor authentication indication
@@ -1622,6 +2079,7 @@ public partial class EpConnection : NetworkConnection, IStore
void AuthenticatonCompleted(Action beforeReady = null) void AuthenticatonCompleted(Action beforeReady = null)
{ {
var generation = Volatile.Read(ref _authenticationAttemptGeneration);
if (this.Instance == null) if (this.Instance == null)
{ {
@@ -1629,10 +2087,31 @@ public partial class EpConnection : NetworkConnection, IStore
Server.Instance.Link + "/" + this.GetHashCode().ToString().Replace("/", "_"), this) Server.Instance.Link + "/" + this.GetHashCode().ToString().Replace("/", "_"), this)
.Then(x => .Then(x =>
{ {
if (!TryPublishAuthenticationReady(
generation,
beforeReady,
out var publicationError))
{
// Warehouse.Put assigns Instance before its asynchronous work
// completes. Remove a connection whose attempt expired or was
// disconnected so the late completion cannot retain it.
if (generation != Volatile.Read(ref _authenticationAttemptGeneration)
|| !IsConnected)
Instance?.Warehouse?.Remove(this);
_authenticated = true; if (publicationError != null)
{
Instance?.Warehouse?.Remove(this);
Global.Log("EpConnection:AuthenticationReady", LogType.Warning,
publicationError.ToString());
FailPendingOpen(publicationError);
Close();
}
beforeReady?.Invoke(); return;
}
CancelAuthenticationDeadline();
Status = EpConnectionStatus.Connected; Status = EpConnectionStatus.Connected;
CompletePendingOpen(); CompletePendingOpen();
OnReady?.Invoke(this); OnReady?.Invoke(this);
@@ -1643,13 +2122,32 @@ public partial class EpConnection : NetworkConnection, IStore
}).Error(x => }).Error(x =>
{ {
FailPendingOpen(x); if (generation == Volatile.Read(ref _authenticationAttemptGeneration))
{
FailPendingOpen(x);
Close();
}
}); });
} }
else else
{ {
_authenticated = true; if (!TryPublishAuthenticationReady(
beforeReady?.Invoke(); generation,
beforeReady,
out var publicationError))
{
if (publicationError != null)
{
Global.Log("EpConnection:AuthenticationReady", LogType.Warning,
publicationError.ToString());
FailPendingOpen(publicationError);
Close();
}
return;
}
CancelAuthenticationDeadline();
Status = EpConnectionStatus.Connected; Status = EpConnectionStatus.Connected;
_session.AuthenticationHandler?.Provider?.Login(_session); _session.AuthenticationHandler?.Provider?.Login(_session);
@@ -2583,27 +3081,28 @@ public partial class EpConnection : NetworkConnection, IStore
_remoteDomain = epContext.Domain ?? address; _remoteDomain = epContext.Domain ?? address;
if (provider != null) _authenticationProvider = provider;
{ _authenticationContext = provider == null
_session.AuthenticationHandler = provider.CreateAuthenticationHandler(new AuthenticationContext() ? null
: new AuthenticationContext()
{ {
Direction = AuthenticationDirection.Initiator, Direction = AuthenticationDirection.Initiator,
Domain = _remoteDomain, Domain = _remoteDomain,
HostName = address, HostName = address,
InitiatorIdentity = epContext.Identity, InitiatorIdentity = epContext.Identity,
ResponderIdentity = epContext.ResponderIdentity,
Mode = epContext.AuthenticationMode, Mode = epContext.AuthenticationMode,
}); };
}
_session.AuthenticationMode = epContext.AuthenticationMode; _session.AuthenticationMode = epContext.AuthenticationMode;
_session.EncryptionMode = epContext.EncryptionMode; _session.EncryptionMode = epContext.EncryptionMode;
_offeredEncryptionProviders = epContext.EncryptionProviders ?? Array.Empty<string>(); _offeredEncryptionProviders = epContext.EncryptionProviders ?? Array.Empty<string>();
_session.LocalIdentity = epContext.Identity; _session.LocalIdentity = epContext.Identity;
ReconnectInterval = epContext.ReconnectInterval; ReconnectInterval = epContext.ReconnectInterval;
AuthenticationTimeout = epContext.AuthenticationTimeout;
ExceptionLevel = epContext.ExceptionLevel; ExceptionLevel = epContext.ExceptionLevel;
UseWebSocket = epContext.UseWebSocket; UseWebSocket = epContext.UseWebSocket;
SecureWebSocket = epContext.SecureWebSocket; SecureWebSocket = epContext.SecureWebSocket;
_remoteDomain = epContext.Domain;
AutoReconnect = epContext.AutoReconnect; AutoReconnect = epContext.AutoReconnect;
_hostname = address; _hostname = address;
_port = port; _port = port;
@@ -2653,6 +3152,13 @@ public partial class EpConnection : NetworkConnection, IStore
if (_session == null) if (_session == null)
throw new AsyncException(ErrorType.Exception, 0, "Session not initialized"); throw new AsyncException(ErrorType.Exception, 0, "Session not initialized");
if (_authenticationProvider != null && _authenticationContext != null)
{
DisposeAuthenticationHandler();
_session.AuthenticationHandler =
_authenticationProvider.CreateAuthenticationHandler(_authenticationContext);
}
BeginPlaintextHandshake(); BeginPlaintextHandshake();
if (socket == null) if (socket == null)
@@ -2680,7 +3186,8 @@ public partial class EpConnection : NetworkConnection, IStore
if (AutoReconnect) if (AutoReconnect)
{ {
Global.Log("EpConnection", LogType.Debug, "Reconnecting socket..."); Global.Log("EpConnection", LogType.Debug, "Reconnecting socket...");
Task.Delay((int)ReconnectInterval).ContinueWith((x) => connectSocket(socket)); Task.Delay(TimeSpan.FromSeconds(ReconnectInterval))
.ContinueWith((x) => connectSocket(socket));
} }
else else
{ {
@@ -2800,6 +3307,10 @@ public partial class EpConnection : NetworkConnection, IStore
protected override void Connected() protected override void Connected()
{ {
lock (_authenticationLifecycleLock)
Interlocked.Increment(ref _authenticationAttemptGeneration);
RestartAuthenticationDeadline();
if (_authDirection == AuthenticationDirection.Initiator) if (_authDirection == AuthenticationDirection.Initiator)
Declare(); Declare();
} }
@@ -2808,9 +3319,11 @@ public partial class EpConnection : NetworkConnection, IStore
{ {
// clean up // clean up
var wasAuthenticated = _authenticated || (_session?.Authenticated ?? false); var wasAuthenticated = _authenticated || (_session?.Authenticated ?? false);
EndAuthenticationAttempt();
TerminateInvocations(); TerminateInvocations();
DisposeSessionEncryption(); DisposeSessionEncryption();
_authenticated = false; _authenticated = false;
_authenticationHandshakeSucceeded = false;
if (_session != null) if (_session != null)
_session.Authenticated = false; _session.Authenticated = false;
Status = EpConnectionStatus.Closed; Status = EpConnectionStatus.Closed;
@@ -2890,15 +3403,24 @@ public partial class EpConnection : NetworkConnection, IStore
//Server.Membership?.Logout(_session); //Server.Membership?.Logout(_session);
} }
} DisposeAuthenticationHandler();
else if (AutoReconnect && !_invalidCredentials)
{
// reconnect
Task.Delay((int)ReconnectInterval).ContinueWith((x) => Reconnect());
} }
else else
{ {
_suspendedResources.Clear(); // Dispose the handler from the completed/aborted attempt before a fast
// reconnect can install a replacement handler on the same Session.
DisposeAuthenticationHandler();
if (AutoReconnect && !_invalidCredentials)
{
Task.Delay(TimeSpan.FromSeconds(ReconnectInterval))
.ContinueWith((x) => Reconnect());
}
else
{
_suspendedResources.Clear();
}
} }
@@ -3,6 +3,7 @@ using Esiur.Data;
using Esiur.Net.Packets; using Esiur.Net.Packets;
using Esiur.Resource; using Esiur.Resource;
using Esiur.Security.Authority; using Esiur.Security.Authority;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography; using Esiur.Security.Cryptography;
using Esiur.Security.Membership; using Esiur.Security.Membership;
using Esiur.Security.Permissions; using Esiur.Security.Permissions;
@@ -39,7 +40,16 @@ public class EpConnectionContext : IResourceContext
public AuthenticationMode AuthenticationMode { get; set; } = AuthenticationMode.None; public AuthenticationMode AuthenticationMode { get; set; } = AuthenticationMode.None;
public string Identity { get; set; } public string Identity { get; set; }
public string AuthenticationProtocol { get; set; } = "hash";
/// <summary>
/// Expected responder identity for responder-authenticated and dual-identity
/// protocols. Leaving this unset accepts any responder identity trusted by the
/// selected authentication provider for the requested domain.
/// </summary>
public string ResponderIdentity { get; set; }
public string AuthenticationProtocol { get; set; }
= PasswordAuthenticationProvider.ProtocolName;
/// <summary> /// <summary>
/// Controls whether the authenticated session key must protect EP traffic. /// Controls whether the authenticated session key must protect EP traffic.
@@ -53,8 +63,15 @@ public class EpConnectionContext : IResourceContext
public bool AutoReconnect { get; set; } = false; public bool AutoReconnect { get; set; } = false;
/// <summary>Delay between reconnect attempts, in seconds.</summary>
public uint ReconnectInterval { get; set; } = 5; public uint ReconnectInterval { get; set; } = 5;
/// <summary>
/// Maximum time allowed for authentication, encryption setup, and any required
/// protected key rotation. A non-positive value disables the deadline.
/// </summary>
public TimeSpan AuthenticationTimeout { get; set; } = TimeSpan.FromSeconds(30);
//public string Username { get; set; } //public string Username { get; set; }
public bool UseWebSocket { get; set; } public bool UseWebSocket { get; set; }
+61 -4
View File
@@ -44,9 +44,18 @@ namespace Esiur.Protocol;
public class EpServer : NetworkServer<EpConnection>, IResource public class EpServer : NetworkServer<EpConnection>, IResource
{ {
sealed class PeerAttemptWindow
{
public DateTime StartedUtc;
public int Count;
}
readonly object _peerConnectionsLock = new object(); readonly object _peerConnectionsLock = new object();
readonly Dictionary<IPAddress, int> _peerConnectionCounts = new Dictionary<IPAddress, int>(); readonly Dictionary<IPAddress, int> _peerConnectionCounts = new Dictionary<IPAddress, int>();
readonly Dictionary<EpConnection, IPAddress> _admittedConnections = new Dictionary<EpConnection, IPAddress>(); readonly Dictionary<EpConnection, IPAddress> _admittedConnections = new Dictionary<EpConnection, IPAddress>();
readonly Dictionary<IPAddress, PeerAttemptWindow> _peerAttemptWindows =
new Dictionary<IPAddress, PeerAttemptWindow>();
uint _attemptSweepSequence;
//[Attribute] //[Attribute]
@@ -75,6 +84,12 @@ public class EpServer : NetworkServer<EpConnection>, IResource
/// </summary> /// </summary>
public bool RequireEncryption { get; set; } public bool RequireEncryption { get; set; }
/// <summary>
/// Maximum time an incoming connection may spend authenticating, negotiating
/// encryption, and completing required protected key rotation.
/// </summary>
public TimeSpan AuthenticationTimeout { get; set; } = TimeSpan.FromSeconds(30);
//[Attribute] //[Attribute]
public bool AllowUnauthorizedAccess { get; set; } public bool AllowUnauthorizedAccess { get; set; }
@@ -180,12 +195,12 @@ public class EpServer : NetworkServer<EpConnection>, IResource
public override void Add(EpConnection connection) public override void Add(EpConnection connection)
{ {
if (!TryAdmitConnection(connection)) if (!TryAdmitConnection(connection, out var rejectionReason))
{ {
Global.Log( Global.Log(
"EpServer:ConnectionLimit", "EpServer:ConnectionLimit",
LogType.Warning, LogType.Warning,
$"Rejected connection from {connection.RemoteEndPoint?.Address}: per-IP limit reached."); $"Rejected connection from {connection.RemoteEndPoint?.Address}: {rejectionReason}");
connection.Close(); connection.Close();
return; return;
} }
@@ -200,6 +215,8 @@ public class EpServer : NetworkServer<EpConnection>, IResource
}); });
connection.ExceptionLevel = ExceptionLevel; connection.ExceptionLevel = ExceptionLevel;
connection.AuthenticationTimeout = AuthenticationTimeout;
connection.RestartAuthenticationDeadline();
base.Add(connection); base.Add(connection);
} }
catch catch
@@ -222,21 +239,61 @@ public class EpServer : NetworkServer<EpConnection>, IResource
} }
} }
private bool TryAdmitConnection(EpConnection connection) private bool TryAdmitConnection(EpConnection connection, out string rejectionReason)
{ {
rejectionReason = null;
var address = NormalizeAddress(connection.RemoteEndPoint?.Address); var address = NormalizeAddress(connection.RemoteEndPoint?.Address);
if (address == null) if (address == null)
return true; return true;
lock (_peerConnectionsLock) lock (_peerConnectionsLock)
{ {
var configuration = Instance.Warehouse.Configuration.Connections;
var now = DateTime.UtcNow;
if (++_attemptSweepSequence % 256 == 0)
{
foreach (var expired in _peerAttemptWindows
.Where(entry => now - entry.Value.StartedUtc
>= configuration.ConnectionAttemptWindow)
.Select(entry => entry.Key)
.ToArray())
_peerAttemptWindows.Remove(expired);
}
if (configuration.MaximumConnectionAttemptsPerIpAddress > 0
&& configuration.ConnectionAttemptWindow > TimeSpan.Zero)
{
if (!_peerAttemptWindows.TryGetValue(address, out var attempts)
|| now - attempts.StartedUtc >= configuration.ConnectionAttemptWindow)
{
attempts = new PeerAttemptWindow
{
StartedUtc = now,
};
_peerAttemptWindows[address] = attempts;
}
if (attempts.Count
>= configuration.MaximumConnectionAttemptsPerIpAddress)
{
rejectionReason = "per-IP connection-attempt rate reached";
return false;
}
attempts.Count++;
}
var count = _peerConnectionCounts.TryGetValue(address, out var current) var count = _peerConnectionCounts.TryGetValue(address, out var current)
? current ? current
: 0; : 0;
var limit = Instance.Warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress; var limit = configuration.MaximumConnectionsPerIpAddress;
if (limit > 0 && count >= limit) if (limit > 0 && count >= limit)
{
rejectionReason = "per-IP concurrent-connection limit reached";
return false; return false;
}
_peerConnectionCounts[address] = count + 1; _peerConnectionCounts[address] = count + 1;
_admittedConnections[connection] = address; _admittedConnections[connection] = address;
@@ -66,6 +66,15 @@ public sealed class ResourceAttachmentConfiguration
public sealed class ConnectionConfiguration public sealed class ConnectionConfiguration
{ {
public int MaximumConnectionsPerIpAddress { get; set; } = 64; public int MaximumConnectionsPerIpAddress { get; set; } = 64;
/// <summary>
/// Maximum connection attempts admitted from one IP during
/// <see cref="ConnectionAttemptWindow"/>. This limits repeated pre-authentication
/// cryptographic and identity-lookup work. A value of zero disables the limit.
/// </summary>
public int MaximumConnectionAttemptsPerIpAddress { get; set; } = 120;
public TimeSpan ConnectionAttemptWindow { get; set; } = TimeSpan.FromMinutes(1);
} }
/// <summary> /// <summary>
@@ -0,0 +1,21 @@
namespace Esiur.Security.Authority;
/// <summary>
/// Result returned by a post-encryption authentication key-rotation step.
/// </summary>
public sealed class AuthenticationKeyRotationResult
{
public AuthenticationKeyRotationRuling Ruling { get; }
public object Data { get; }
public string Error { get; }
public AuthenticationKeyRotationResult(
AuthenticationKeyRotationRuling ruling,
object data = null,
string error = null)
{
Ruling = ruling;
Data = data;
Error = error;
}
}
@@ -0,0 +1,11 @@
namespace Esiur.Security.Authority;
/// <summary>
/// Describes the current state of a post-encryption authentication key rotation.
/// </summary>
public enum AuthenticationKeyRotationRuling
{
Failed,
InProgress,
Succeeded,
}
@@ -6,7 +6,11 @@ namespace Esiur.Security.Authority
{ {
public enum AuthenticationProtocol public enum AuthenticationProtocol
{ {
Hash = 0, Password = 0,
[Obsolete("Use Password instead.")]
Hash = Password,
PPAP = 1, PPAP = 1,
} }
} }
@@ -26,7 +26,17 @@ namespace Esiur.Security.Authority
LocalIdentity = localIdentity; LocalIdentity = localIdentity;
RemoteIdentity = remoteIdentity; RemoteIdentity = remoteIdentity;
AuthenticationData = authenticationData; AuthenticationData = authenticationData;
SessionKey = sessionKey; // AuthenticationResult owns its key buffer. This lets transports erase the
// short-lived handoff copy after adopting their own session key without
// mutating provider-wide or cached key material.
SessionKey = sessionKey == null ? null : (byte[])sessionKey.Clone();
}
internal void ClearSessionKey()
{
if (SessionKey != null)
Array.Clear(SessionKey, 0, SessionKey.Length);
SessionKey = null;
} }
} }
} }
@@ -0,0 +1,24 @@
namespace Esiur.Security.Authority;
/// <summary>
/// Optional authentication-handler capability for rotating authentication keys after
/// the initial session key has enabled authenticated encrypted transport protection.
/// Transport implementations MUST NOT call either exchange method or transmit their
/// data before that protection is active; rotation payloads can contain fresh verifier
/// material that must never appear on a plaintext channel.
/// </summary>
public interface IAuthenticationKeyRotationHandler
{
bool RequiresKeyRotation { get; }
/// <summary>
/// Starts key rotation on the connection initiator. This is called only after the
/// encrypted Established event has been received.
/// </summary>
AuthenticationKeyRotationResult BeginKeyRotation();
/// <summary>
/// Processes one encrypted key-rotation message from the peer.
/// </summary>
AuthenticationKeyRotationResult ProcessKeyRotation(object data);
}
@@ -1,27 +0,0 @@
using Esiur.Core;
using System;
using System.Collections.Generic;
using System.Text;
namespace Esiur.Security.Authority
{
internal class PpapAuthenticationProvider : IAuthenticationProvider
{
public string DefaultName => "PPAP";
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
{
throw new NotImplementedException();
}
public AsyncReply<bool> Login(Session session)
{
throw new NotImplementedException();
}
public AsyncReply<bool> Logout(Session session)
{
throw new NotImplementedException();
}
}
}
@@ -10,7 +10,8 @@ using Esiur.Data.Types;
namespace Esiur.Security.Authority.Providers namespace Esiur.Security.Authority.Providers
{ {
/// <summary> /// <summary>
/// Implements the "hash" authentication protocol: a SHA3 nonce/challenge-response /// Implements the "password-sha3-v1" authentication protocol: a SHA3
/// nonce/challenge-response
/// handshake that mutually proves knowledge of a salted password hash without sending /// handshake that mutually proves knowledge of a salted password hash without sending
/// the password, and derives a 512-bit session key. Supports initiator-only, responder-only /// the password, and derives a 512-bit session key. Supports initiator-only, responder-only
/// and dual identity modes. All challenge comparisons are constant-time and remote material /// and dual identity modes. All challenge comparisons are constant-time and remote material
@@ -18,7 +19,7 @@ namespace Esiur.Security.Authority.Providers
/// </summary> /// </summary>
public class PasswordAuthenticationHandler : IAuthenticationHandler public class PasswordAuthenticationHandler : IAuthenticationHandler
{ {
public string Protocol => "hash"; public string Protocol => PasswordAuthenticationProvider.ProtocolName;
// Length, in bytes, of the random nonces exchanged during the handshake. // Length, in bytes, of the random nonces exchanged during the handshake.
// Remote nonces are validated against this to reject malformed or weak input. // Remote nonces are validated against this to reject malformed or weak input.
@@ -7,7 +7,19 @@ namespace Esiur.Security.Authority.Providers
{ {
public class PasswordAuthenticationProvider : IAuthenticationProvider public class PasswordAuthenticationProvider : IAuthenticationProvider
{ {
public string DefaultName => "hash"; /// <summary>
/// Canonical protocol name used for registration and EP negotiation.
/// </summary>
public const string ProtocolName = "password-sha3-v1";
/// <summary>
/// Previous protocol name, retained only to make explicit migration aliases possible.
/// New connections should use <see cref="ProtocolName"/>.
/// </summary>
[Obsolete("Use ProtocolName (`password-sha3-v1`) for new connections.")]
public const string LegacyProtocolName = "hash";
public string DefaultName => ProtocolName;
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context) public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
{ {
@@ -0,0 +1,453 @@
using System;
using System.IO;
namespace Esiur.Security.Authority.Providers.Ppap;
public sealed partial class PpapAuthenticationHandler
{
enum RotationState
{
NotStarted,
SubjectAwaitChallenge,
SubjectAwaitCommit,
VerifierAwaitProof,
ResponderAwaitNext,
InitiatorAwaitResponderOffer,
InitiatorAwaitResponderCommitAck,
ResponderAwaitDone,
InitiatorDoneSent,
Complete,
Failed,
}
RotationState _rotationState;
PpapIdentityRole _rotationRole;
PpapRegistrationRecord _pendingRotation;
byte[] _rotationPrivateKey;
byte[] _rotationChallengeSecret;
byte[] _rotationOffer;
byte[] _rotationChallenge;
/// <summary>
/// True for every live PPAP handler because protocol completion requires an
/// encrypted post-authentication phase. Static identities use a no-op Done exchange.
/// </summary>
public bool RequiresKeyRotation
{
get
{
lock (_sync)
{
// PPAP always requires the encrypted post-authentication phase. It is
// a no-op Done exchange when every authenticated identity is static.
return _state != HandshakeState.Failed
&& _state != HandshakeState.Disposed;
}
}
}
public AuthenticationKeyRotationResult BeginKeyRotation()
{
lock (_sync)
{
try
{
if (_direction != AuthenticationDirection.Initiator
|| _state != HandshakeState.Complete
|| _rotationState != RotationState.NotStarted
|| !RequiresKeyRotation)
throw new InvalidOperationException("PPAP key rotation cannot be started in the current state.");
if (RoleRequiresRotation(PpapIdentityRole.Initiator))
return CreateRotationOffer(PpapIdentityRole.Initiator);
if (!RoleRequiresRotation(PpapIdentityRole.Responder))
{
_rotationState = RotationState.InitiatorDoneSent;
var done = PpapWire.EncodeRotationDone();
ClearCompletedRotationSecrets();
return RotationSucceeded(done);
}
_rotationState = RotationState.InitiatorAwaitResponderOffer;
return RotationInProgress(PpapWire.EncodeRotationStart(
PpapIdentityRole.Responder));
}
catch
{
_rotationState = RotationState.Failed;
ClearCompletedRotationSecrets();
return RotationFailed();
}
}
}
public AuthenticationKeyRotationResult ProcessKeyRotation(object data)
{
lock (_sync)
{
try
{
if (_state != HandshakeState.Complete || !RequiresKeyRotation)
throw new InvalidOperationException("PPAP key rotation is not available.");
if (_direction == AuthenticationDirection.Initiator)
return ProcessInitiatorRotation(data);
return ProcessResponderRotation(data);
}
catch
{
_rotationState = RotationState.Failed;
ClearCompletedRotationSecrets();
return RotationFailed();
}
}
}
AuthenticationKeyRotationResult ProcessInitiatorRotation(object data)
{
if (_rotationState == RotationState.SubjectAwaitChallenge)
return ProcessRotationChallenge(data, PpapIdentityRole.Initiator);
if (_rotationState == RotationState.SubjectAwaitCommit)
{
var committedVersion = PpapWire.DecodeRotationCommit(data,
PpapIdentityRole.Initiator);
CommitSubjectRotation(PpapIdentityRole.Initiator, committedVersion);
if (RoleRequiresRotation(PpapIdentityRole.Responder))
{
_rotationState = RotationState.InitiatorAwaitResponderOffer;
return RotationInProgress(PpapWire.EncodeRotationStart(
PpapIdentityRole.Responder));
}
_rotationState = RotationState.InitiatorDoneSent;
var done = PpapWire.EncodeRotationDone();
ClearCompletedRotationSecrets();
return RotationSucceeded(done);
}
if (_rotationState == RotationState.InitiatorAwaitResponderOffer)
return ProcessRotationOffer(data, PpapIdentityRole.Responder);
if (_rotationState == RotationState.VerifierAwaitProof)
return ProcessRotationProof(data, PpapIdentityRole.Responder);
if (_rotationState == RotationState.InitiatorAwaitResponderCommitAck)
{
var version = PpapWire.DecodeRotationCommitAck(data,
PpapIdentityRole.Responder);
if (_responderRegistration == null
|| version != _responderRegistration.Version)
throw new InvalidDataException("Unexpected responder rotation version.");
_rotationState = RotationState.InitiatorDoneSent;
var done = PpapWire.EncodeRotationDone();
ClearCompletedRotationSecrets();
return RotationSucceeded(done);
}
throw new InvalidDataException("Unexpected PPAP initiator rotation message.");
}
AuthenticationKeyRotationResult ProcessResponderRotation(object data)
{
if (_rotationState == RotationState.NotStarted)
{
var type = PpapWire.PeekMessageType(data);
if (type == PpapMessageType.RotationDone
&& !RoleRequiresRotation(PpapIdentityRole.Initiator)
&& !RoleRequiresRotation(PpapIdentityRole.Responder))
return ProcessRotationDone(data);
if (type == PpapMessageType.RotationOffer
&& RoleRequiresRotation(PpapIdentityRole.Initiator))
return ProcessRotationOffer(data, PpapIdentityRole.Initiator);
if (type == PpapMessageType.RotationStart
&& !RoleRequiresRotation(PpapIdentityRole.Initiator)
&& RoleRequiresRotation(PpapIdentityRole.Responder))
{
var role = PpapWire.DecodeRotationStart(data);
if (role != PpapIdentityRole.Responder)
throw new InvalidDataException("Unexpected rotation role.");
return CreateRotationOffer(PpapIdentityRole.Responder);
}
throw new InvalidDataException("Unexpected initial PPAP rotation message.");
}
if (_rotationState == RotationState.VerifierAwaitProof)
return ProcessRotationProof(data, PpapIdentityRole.Initiator);
if (_rotationState == RotationState.ResponderAwaitNext)
{
var type = PpapWire.PeekMessageType(data);
if (type == PpapMessageType.RotationStart
&& RoleRequiresRotation(PpapIdentityRole.Responder))
{
var role = PpapWire.DecodeRotationStart(data);
if (role != PpapIdentityRole.Responder)
throw new InvalidDataException("Unexpected rotation role.");
return CreateRotationOffer(PpapIdentityRole.Responder);
}
if (type == PpapMessageType.RotationDone
&& !RoleRequiresRotation(PpapIdentityRole.Responder))
return ProcessRotationDone(data);
throw new InvalidDataException("Unexpected PPAP rotation continuation.");
}
if (_rotationState == RotationState.SubjectAwaitChallenge)
return ProcessRotationChallenge(data, PpapIdentityRole.Responder);
if (_rotationState == RotationState.SubjectAwaitCommit)
{
var committedVersion = PpapWire.DecodeRotationCommit(data,
PpapIdentityRole.Responder);
CommitSubjectRotation(PpapIdentityRole.Responder, committedVersion);
_rotationState = RotationState.ResponderAwaitDone;
return RotationInProgress(PpapWire.EncodeRotationCommitAck(
PpapIdentityRole.Responder, committedVersion));
}
if (_rotationState == RotationState.ResponderAwaitDone)
return ProcessRotationDone(data);
throw new InvalidDataException("Unexpected PPAP responder rotation message.");
}
AuthenticationKeyRotationResult CreateRotationOffer(PpapIdentityRole role)
{
if (!LocalIsSubject(role) || !RoleRequiresRotation(role)
|| _localIdentity == null
|| _localIdentity.Kind != PpapIdentityKind.PasswordDerived)
throw new InvalidOperationException("The local endpoint cannot rotate this identity role.");
var current = GetRoleRegistration(role);
var nonce = PpapCryptography.RandomBytes(PpapProtocol.RegistrationNonceLength);
byte[] publicKey = null;
try
{
_rotationPrivateKey = _provider.DerivePrivateKey(_localIdentity,
_domain, nonce, _localIdentity.KdfProfile,
postAuthentication: true);
publicKey = PpapCryptography.GetPublicKey(_rotationPrivateKey);
_pendingRotation = new PpapRegistrationRecord(current.Version + 1,
current.Identity, PpapIdentityKind.PasswordDerived, nonce,
publicKey, _localIdentity.KdfProfile);
var offer = PpapWire.EncodeRotationOffer(role, current.Identity,
current.Version, nonce, publicKey, _localIdentity.KdfProfile);
ReplaceRotationBytes(ref _rotationOffer, offer);
_rotationRole = role;
_rotationState = RotationState.SubjectAwaitChallenge;
return RotationInProgress(offer);
}
finally
{
PpapCryptography.Clear(publicKey);
PpapCryptography.Clear(nonce);
}
}
AuthenticationKeyRotationResult ProcessRotationOffer(object data,
PpapIdentityRole expectedRole)
{
if (LocalIsSubject(expectedRole))
throw new InvalidOperationException("A subject cannot verify its own rotation.");
var raw = RequireBytes(data);
var offer = PpapWire.DecodeRotationOffer(raw);
if (offer.Role != expectedRole)
throw new InvalidDataException("Unexpected rotation role.");
var current = GetRoleRegistration(expectedRole);
if (current == null
|| current.Kind != PpapIdentityKind.PasswordDerived
|| !string.Equals(current.Identity, offer.Identity, StringComparison.Ordinal)
|| current.Version != offer.ExpectedVersion
|| !current.KdfProfile.Equals(offer.KdfProfile))
throw new InvalidDataException("The rotation offer does not match the authenticated registration.");
_pendingRotation = new PpapRegistrationRecord(current.Version + 1,
current.Identity, PpapIdentityKind.PasswordDerived, offer.Nonce,
offer.EncapsulationKey, offer.KdfProfile);
PpapCryptography.Encapsulate(offer.EncapsulationKey,
out var ciphertext, out _rotationChallengeSecret);
try
{
var challenge = PpapWire.EncodeRotationChallenge(expectedRole,
ciphertext);
ReplaceRotationBytes(ref _rotationOffer, raw);
ReplaceRotationBytes(ref _rotationChallenge, challenge);
_rotationRole = expectedRole;
_rotationState = RotationState.VerifierAwaitProof;
return RotationInProgress(challenge);
}
finally
{
PpapCryptography.Clear(ciphertext);
PpapCryptography.Clear(offer.Nonce);
PpapCryptography.Clear(offer.EncapsulationKey);
}
}
AuthenticationKeyRotationResult ProcessRotationChallenge(object data,
PpapIdentityRole expectedRole)
{
if (!LocalIsSubject(expectedRole) || _rotationRole != expectedRole
|| _rotationPrivateKey == null || _pendingRotation == null)
throw new InvalidOperationException("Unexpected rotation challenge.");
var raw = RequireBytes(data);
var challenge = PpapWire.DecodeRotationChallenge(raw);
if (challenge.Role != expectedRole)
throw new InvalidDataException("Unexpected rotation challenge role.");
var secret = PpapCryptography.Decapsulate(_rotationPrivateKey,
challenge.Ciphertext);
try
{
ReplaceRotationBytes(ref _rotationChallenge, raw);
var proof = PpapCryptography.ComputeRotationProof(secret,
_sessionKey, _rotationOffer, _rotationChallenge);
try
{
_rotationState = RotationState.SubjectAwaitCommit;
return RotationInProgress(PpapWire.EncodeRotationProof(
expectedRole, proof));
}
finally
{
PpapCryptography.Clear(proof);
}
}
finally
{
PpapCryptography.Clear(secret);
PpapCryptography.Clear(challenge.Ciphertext);
PpapCryptography.Clear(_rotationPrivateKey);
_rotationPrivateKey = null;
}
}
AuthenticationKeyRotationResult ProcessRotationProof(object data,
PpapIdentityRole expectedRole)
{
if (LocalIsSubject(expectedRole) || _rotationRole != expectedRole
|| _rotationChallengeSecret == null || _pendingRotation == null)
throw new InvalidOperationException("Unexpected rotation proof.");
var proof = PpapWire.DecodeRotationProof(data);
if (proof.Role != expectedRole)
throw new InvalidDataException("Unexpected rotation proof role.");
var expected = PpapCryptography.ComputeRotationProof(
_rotationChallengeSecret, _sessionKey, _rotationOffer,
_rotationChallenge);
var verified = PpapCryptography.FixedTimeEquals(expected, proof.Proof);
PpapCryptography.Clear(expected);
PpapCryptography.Clear(proof.Proof);
if (!verified)
throw new InvalidDataException("Rotation proof verification failed.");
var current = GetRoleRegistration(expectedRole);
if (!_provider.Registrations.TryRotate(_domain, current.Identity,
current.Version, _pendingRotation))
throw new InvalidOperationException("The registration changed concurrently.");
SetRoleRegistration(expectedRole, _pendingRotation);
var committedVersion = _pendingRotation.Version;
_pendingRotation = null;
ClearRotationExchangeSecrets();
if (expectedRole == PpapIdentityRole.Initiator)
_rotationState = RotationState.ResponderAwaitNext;
else
_rotationState = RotationState.InitiatorAwaitResponderCommitAck;
return RotationInProgress(PpapWire.EncodeRotationCommit(expectedRole,
committedVersion));
}
void CommitSubjectRotation(PpapIdentityRole role, long committedVersion)
{
if (!LocalIsSubject(role) || _rotationRole != role
|| _pendingRotation == null
|| _pendingRotation.Version != committedVersion)
throw new InvalidDataException("Unexpected rotation commit.");
SetRoleRegistration(role, _pendingRotation);
_pendingRotation = null;
ClearRotationExchangeSecrets();
}
AuthenticationKeyRotationResult ProcessRotationDone(object data)
{
PpapWire.DecodeRotationDone(data);
_rotationState = RotationState.Complete;
ClearCompletedRotationSecrets();
return RotationSucceeded(null);
}
bool RoleRequiresRotation(PpapIdentityRole role)
{
var registration = GetRoleRegistration(role);
return registration != null
&& registration.Kind == PpapIdentityKind.PasswordDerived;
}
bool LocalIsSubject(PpapIdentityRole role)
=> (_direction == AuthenticationDirection.Initiator
&& role == PpapIdentityRole.Initiator)
|| (_direction == AuthenticationDirection.Responder
&& role == PpapIdentityRole.Responder);
PpapRegistrationRecord GetRoleRegistration(PpapIdentityRole role)
=> role == PpapIdentityRole.Initiator
? _initiatorRegistration : _responderRegistration;
void SetRoleRegistration(PpapIdentityRole role,
PpapRegistrationRecord registration)
{
if (role == PpapIdentityRole.Initiator)
_initiatorRegistration = registration;
else
_responderRegistration = registration;
}
static AuthenticationKeyRotationResult RotationInProgress(byte[] data)
=> new AuthenticationKeyRotationResult(
AuthenticationKeyRotationRuling.InProgress, data);
static AuthenticationKeyRotationResult RotationSucceeded(byte[] data)
=> new AuthenticationKeyRotationResult(
AuthenticationKeyRotationRuling.Succeeded, data);
static AuthenticationKeyRotationResult RotationFailed()
=> new AuthenticationKeyRotationResult(
AuthenticationKeyRotationRuling.Failed, null,
"PPAP authentication key rotation failed.");
static void ReplaceRotationBytes(ref byte[] target, byte[] value)
{
PpapCryptography.Clear(target);
target = value == null ? null : (byte[])value.Clone();
}
void ClearRotationExchangeSecrets()
{
PpapCryptography.Clear(_rotationPrivateKey);
PpapCryptography.Clear(_rotationChallengeSecret);
PpapCryptography.Clear(_rotationOffer);
PpapCryptography.Clear(_rotationChallenge);
_rotationPrivateKey = null;
_rotationChallengeSecret = null;
_rotationOffer = null;
_rotationChallenge = null;
}
void ClearRotationSecrets()
{
ClearRotationExchangeSecrets();
_pendingRotation = null;
}
void ClearCompletedRotationSecrets()
{
ClearRotationSecrets();
PpapCryptography.Clear(_sessionKey);
_sessionKey = null;
}
}
@@ -0,0 +1,547 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace Esiur.Security.Authority.Providers.Ppap;
/// <summary>
/// Per-connection PPAP ML-KEM state. A handler instance must not be reused.
/// </summary>
public sealed partial class PpapAuthenticationHandler : IAuthenticationHandler,
IAuthenticationKeyRotationHandler, IDisposable
{
enum HandshakeState
{
InitiatorStart,
InitiatorAwaitServerHello,
InitiatorAwaitResponderProof,
ResponderAwaitClientHello,
ResponderAwaitInitiatorProof,
ResponderAwaitInitiatorFinished,
Complete,
Failed,
Disposed,
}
readonly object _sync = new();
readonly PpapAuthenticationProvider _provider;
readonly PpapLocalIdentity _localIdentity;
readonly AuthenticationDirection _direction;
readonly AuthenticationMode _mode;
readonly string _domain;
readonly string _expectedInitiatorIdentity;
readonly string _expectedResponderIdentity;
readonly bool _authenticateInitiator;
readonly bool _authenticateResponder;
readonly List<byte[]> _transcript = new();
HandshakeState _state;
string _initiatorIdentity;
string _responderIdentity;
PpapRegistrationRecord _initiatorRegistration;
PpapRegistrationRecord _responderRegistration;
byte[] _initiatorMask;
byte[] _responderMask;
byte[] _ephemeralPrivateKey;
byte[] _ephemeralSecret;
byte[] _initiatorIdentitySecret;
byte[] _responderIdentitySecret;
byte[] _sessionKey;
byte[] _initiatorFinishedKey;
byte[] _responderFinishedKey;
byte[] _transcriptHash;
byte[] _authenticationContext;
public IAuthenticationProvider Provider => _provider;
public string Protocol => PpapProtocol.Name;
internal PpapAuthenticationHandler(PpapAuthenticationProvider provider,
AuthenticationContext context)
{
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
_localIdentity = provider.ResolveLocalIdentity(context);
_direction = context.Direction;
_mode = context.Mode;
_domain = PpapCryptography.NormalizeDomain(context.Domain);
_expectedInitiatorIdentity = NormalizeOptionalIdentity(context.InitiatorIdentity);
_expectedResponderIdentity = NormalizeOptionalIdentity(context.ResponderIdentity);
if (_direction != AuthenticationDirection.Initiator
&& _direction != AuthenticationDirection.Responder)
throw new ArgumentOutOfRangeException(nameof(context.Direction));
if (_mode != AuthenticationMode.InitializerIdentity
&& _mode != AuthenticationMode.ResponderIdentity
&& _mode != AuthenticationMode.DualIdentity)
throw new ArgumentOutOfRangeException(nameof(context.Mode));
_authenticateInitiator = _mode == AuthenticationMode.InitializerIdentity
|| _mode == AuthenticationMode.DualIdentity;
_authenticateResponder = _mode == AuthenticationMode.ResponderIdentity
|| _mode == AuthenticationMode.DualIdentity;
var localIsAuthenticated = _direction == AuthenticationDirection.Initiator
? _authenticateInitiator
: _authenticateResponder;
if (localIsAuthenticated && _localIdentity == null)
throw new InvalidOperationException(
"This PPAP mode requires a provider-configured local identity.");
if (_localIdentity != null)
{
var expectedLocal = _direction == AuthenticationDirection.Initiator
? _expectedInitiatorIdentity
: _expectedResponderIdentity;
if (expectedLocal != null && !string.Equals(expectedLocal,
_localIdentity.Identity, StringComparison.Ordinal))
throw new InvalidOperationException(
"The connection identity does not match the PPAP provider identity.");
}
_state = _direction == AuthenticationDirection.Initiator
? HandshakeState.InitiatorStart
: HandshakeState.ResponderAwaitClientHello;
}
public AuthenticationResult Process(object authData)
{
lock (_sync)
{
if (_state == HandshakeState.Disposed)
return Failed();
try
{
if (_direction == AuthenticationDirection.Initiator)
return ProcessInitiator(authData);
return ProcessResponder(authData);
}
catch
{
_state = HandshakeState.Failed;
ClearHandshakeSecrets(clearSessionKey: true);
ClearRotationSecrets();
return Failed();
}
}
}
AuthenticationResult ProcessInitiator(object authData)
{
if (_state == HandshakeState.InitiatorStart)
{
if (authData != null)
throw new InvalidDataException("Unexpected initial PPAP data.");
if (_authenticateInitiator)
_initiatorIdentity = _localIdentity.Identity;
PpapCryptography.GenerateKeyPair(out _ephemeralPrivateKey,
out var ephemeralPublicKey);
_initiatorMask = PpapCryptography.RandomBytes(PpapProtocol.IdentityMaskLength);
byte[] message = null;
try
{
message = PpapWire.EncodeClientHello(_mode, _domain,
ephemeralPublicKey, _initiatorMask);
AddTranscript(message);
_state = HandshakeState.InitiatorAwaitServerHello;
return InProgress(message);
}
finally
{
PpapCryptography.Clear(ephemeralPublicKey);
}
}
if (_state == HandshakeState.InitiatorAwaitServerHello)
{
var raw = RequireBytes(authData);
var hello = PpapWire.DecodeServerHello(raw, _authenticateResponder);
AddTranscript(raw);
_ephemeralSecret = PpapCryptography.Decapsulate(
_ephemeralPrivateKey, hello.EphemeralCiphertext);
PpapCryptography.Clear(_ephemeralPrivateKey);
_ephemeralPrivateKey = null;
_responderMask = hello.ResponderMask;
byte[] maskedInitiator = null;
byte[] responderCiphertext = null;
PpapRegistrationDescriptor responderDescriptor = null;
if (_authenticateInitiator)
maskedInitiator = PpapCryptography.MaskIdentity(_domain,
_initiatorIdentity, _responderMask, _ephemeralSecret);
if (_authenticateResponder)
{
_responderRegistration = _provider.Registrations.ResolveMasked(
_domain, _initiatorMask, _ephemeralSecret,
hello.MaskedResponderIdentity);
ValidateRemoteRegistration(_responderRegistration,
_expectedResponderIdentity);
_responderIdentity = _responderRegistration.Identity;
PpapCryptography.Encapsulate(
_responderRegistration.EncapsulationKeyBytes,
out responderCiphertext, out _responderIdentitySecret);
responderDescriptor = PpapRegistrationDescriptor.FromRecord(
_responderRegistration);
}
try
{
var message = PpapWire.EncodeInitiatorProof(maskedInitiator,
responderCiphertext, responderDescriptor, _domain,
_ephemeralSecret);
AddTranscript(message);
_state = HandshakeState.InitiatorAwaitResponderProof;
return InProgress(message);
}
finally
{
PpapCryptography.Clear(maskedInitiator);
PpapCryptography.Clear(responderCiphertext);
}
}
if (_state == HandshakeState.InitiatorAwaitResponderProof)
{
var proof = PpapWire.DecodeResponderProof(authData,
_authenticateInitiator, _domain, _ephemeralSecret);
AddTranscript(proof.TranscriptCore);
if (_authenticateInitiator)
{
ValidateLocalDescriptor(proof.InitiatorRegistration,
_localIdentity);
byte[] privateKey = null;
byte[] publicKey = null;
try
{
privateKey = _provider.DerivePrivateKey(_localIdentity, _domain,
proof.InitiatorRegistration.Nonce,
proof.InitiatorRegistration.KdfProfile);
publicKey = PpapCryptography.GetPublicKey(privateKey);
_initiatorIdentitySecret = PpapCryptography.Decapsulate(
privateKey, proof.InitiatorCiphertext);
_initiatorRegistration = new PpapRegistrationRecord(
proof.InitiatorRegistration.Version, _initiatorIdentity,
proof.InitiatorRegistration.Kind,
proof.InitiatorRegistration.Nonce, publicKey,
proof.InitiatorRegistration.KdfProfile);
}
finally
{
PpapCryptography.Clear(privateKey);
PpapCryptography.Clear(publicKey);
}
}
DeriveHandshakeKeys();
var expected = PpapCryptography.ComputeFinished(false,
_responderFinishedKey, _transcriptHash);
var verified = PpapCryptography.FixedTimeEquals(expected, proof.Finished);
PpapCryptography.Clear(expected);
PpapCryptography.Clear(proof.Finished);
PpapCryptography.Clear(proof.TranscriptCore);
if (!verified)
throw new InvalidDataException("Responder Finished verification failed.");
var initiatorFinished = PpapCryptography.ComputeFinished(true,
_initiatorFinishedKey, _transcriptHash);
try
{
var message = PpapWire.EncodeInitiatorFinished(initiatorFinished);
_state = HandshakeState.Complete;
ClearPostConfirmationSecrets();
return Succeeded(message);
}
finally
{
PpapCryptography.Clear(initiatorFinished);
}
}
throw new InvalidDataException("Invalid PPAP initiator state.");
}
AuthenticationResult ProcessResponder(object authData)
{
if (_state == HandshakeState.ResponderAwaitClientHello)
{
var raw = RequireBytes(authData);
var hello = PpapWire.DecodeClientHello(raw);
if (hello.Mode != _mode
|| !string.Equals(hello.Domain, _domain, StringComparison.Ordinal))
throw new InvalidDataException("PPAP context mismatch.");
AddTranscript(raw);
_initiatorMask = hello.InitiatorMask;
if (_authenticateResponder)
_responderIdentity = _localIdentity.Identity;
PpapCryptography.Encapsulate(hello.EphemeralKey,
out var ephemeralCiphertext, out _ephemeralSecret);
_responderMask = PpapCryptography.RandomBytes(
PpapProtocol.IdentityMaskLength);
byte[] maskedResponder = null;
if (_authenticateResponder)
maskedResponder = PpapCryptography.MaskIdentity(_domain,
_responderIdentity, _initiatorMask, _ephemeralSecret);
try
{
var message = PpapWire.EncodeServerHello(ephemeralCiphertext,
_responderMask, maskedResponder);
AddTranscript(message);
_state = HandshakeState.ResponderAwaitInitiatorProof;
return InProgress(message);
}
finally
{
PpapCryptography.Clear(ephemeralCiphertext);
PpapCryptography.Clear(maskedResponder);
PpapCryptography.Clear(hello.EphemeralKey);
}
}
if (_state == HandshakeState.ResponderAwaitInitiatorProof)
{
var raw = RequireBytes(authData);
var proof = PpapWire.DecodeInitiatorProof(raw,
_authenticateInitiator, _authenticateResponder, _domain,
_ephemeralSecret);
AddTranscript(raw);
if (_authenticateResponder)
{
ValidateLocalDescriptor(proof.ResponderRegistration,
_localIdentity);
byte[] privateKey = null;
byte[] publicKey = null;
try
{
privateKey = _provider.DerivePrivateKey(_localIdentity, _domain,
proof.ResponderRegistration.Nonce,
proof.ResponderRegistration.KdfProfile);
publicKey = PpapCryptography.GetPublicKey(privateKey);
_responderIdentitySecret = PpapCryptography.Decapsulate(
privateKey, proof.ResponderCiphertext);
_responderRegistration = new PpapRegistrationRecord(
proof.ResponderRegistration.Version, _responderIdentity,
proof.ResponderRegistration.Kind,
proof.ResponderRegistration.Nonce, publicKey,
proof.ResponderRegistration.KdfProfile);
}
finally
{
PpapCryptography.Clear(privateKey);
PpapCryptography.Clear(publicKey);
}
}
byte[] initiatorCiphertext = null;
PpapRegistrationDescriptor initiatorDescriptor = null;
if (_authenticateInitiator)
{
_initiatorRegistration = _provider.Registrations.ResolveMasked(
_domain, _responderMask, _ephemeralSecret,
proof.MaskedInitiatorIdentity);
ValidateRemoteRegistration(_initiatorRegistration,
_expectedInitiatorIdentity);
_initiatorIdentity = _initiatorRegistration.Identity;
PpapCryptography.Encapsulate(
_initiatorRegistration.EncapsulationKeyBytes,
out initiatorCiphertext, out _initiatorIdentitySecret);
initiatorDescriptor = PpapRegistrationDescriptor.FromRecord(
_initiatorRegistration);
}
byte[] core = null;
byte[] protectedInitiatorDescriptor = null;
byte[] responderFinished = null;
try
{
core = PpapWire.EncodeResponderProofCore(initiatorCiphertext,
initiatorDescriptor, _domain, _ephemeralSecret,
out protectedInitiatorDescriptor);
AddTranscript(core);
DeriveHandshakeKeys();
responderFinished = PpapCryptography.ComputeFinished(false,
_responderFinishedKey, _transcriptHash);
var message = PpapWire.EncodeResponderProof(initiatorCiphertext,
protectedInitiatorDescriptor, responderFinished);
_state = HandshakeState.ResponderAwaitInitiatorFinished;
return InProgress(message);
}
finally
{
PpapCryptography.Clear(core);
PpapCryptography.Clear(protectedInitiatorDescriptor);
PpapCryptography.Clear(initiatorCiphertext);
PpapCryptography.Clear(responderFinished);
}
}
if (_state == HandshakeState.ResponderAwaitInitiatorFinished)
{
var remoteFinished = PpapWire.DecodeInitiatorFinished(authData);
var expected = PpapCryptography.ComputeFinished(true,
_initiatorFinishedKey, _transcriptHash);
var verified = PpapCryptography.FixedTimeEquals(expected, remoteFinished);
PpapCryptography.Clear(expected);
PpapCryptography.Clear(remoteFinished);
if (!verified)
throw new InvalidDataException("Initiator Finished verification failed.");
_state = HandshakeState.Complete;
ClearPostConfirmationSecrets();
return Succeeded(null);
}
throw new InvalidDataException("Invalid PPAP responder state.");
}
void DeriveHandshakeKeys()
{
_authenticationContext = PpapWire.EncodeAuthenticationContext(_mode,
_domain, _initiatorIdentity,
_authenticateInitiator ? (PpapIdentityKind?)GetInitiatorKind() : null,
_responderIdentity,
_authenticateResponder ? (PpapIdentityKind?)GetResponderKind() : null);
_transcriptHash = PpapCryptography.ComputeTranscriptHash(
_transcript, _authenticationContext);
PpapCryptography.DeriveHandshakeKeys(_ephemeralSecret,
_initiatorIdentitySecret, _responderIdentitySecret,
_transcriptHash, _authenticationContext,
out _sessionKey, out _initiatorFinishedKey,
out _responderFinishedKey);
PpapCryptography.Clear(_ephemeralSecret);
PpapCryptography.Clear(_initiatorIdentitySecret);
PpapCryptography.Clear(_responderIdentitySecret);
_ephemeralSecret = null;
_initiatorIdentitySecret = null;
_responderIdentitySecret = null;
}
PpapIdentityKind GetInitiatorKind()
=> _initiatorRegistration?.Kind ?? _localIdentity.Kind;
PpapIdentityKind GetResponderKind()
=> _responderRegistration?.Kind ?? _localIdentity.Kind;
void ValidateRemoteRegistration(PpapRegistrationRecord record,
string expectedIdentity)
{
if (record == null)
throw new InvalidDataException("The masked identity could not be resolved.");
if (expectedIdentity != null && !string.Equals(expectedIdentity,
record.Identity, StringComparison.Ordinal))
throw new InvalidDataException("The resolved identity was not expected.");
}
static void ValidateLocalDescriptor(PpapRegistrationDescriptor descriptor,
PpapLocalIdentity identity)
{
if (descriptor == null || identity == null || descriptor.Kind != identity.Kind)
throw new InvalidDataException("The local registration descriptor is invalid.");
if (identity.Kind == PpapIdentityKind.PasswordDerived
&& !identity.KdfProfile.Equals(descriptor.KdfProfile))
throw new InvalidDataException("The local registration KDF profile is not accepted.");
}
void AddTranscript(byte[] message)
{
if (message == null || message.Length == 0
|| message.Length > PpapProtocol.MaximumWireMessageBytes)
throw new InvalidDataException("Invalid transcript message.");
_transcript.Add((byte[])message.Clone());
}
static byte[] RequireBytes(object data)
{
if (!(data is byte[] value))
throw new InvalidDataException("PPAP data must be a byte array.");
return value;
}
AuthenticationResult InProgress(byte[] data)
=> new AuthenticationResult(AuthenticationRuling.InProgress, data);
AuthenticationResult Succeeded(byte[] data)
{
var local = _direction == AuthenticationDirection.Initiator
? _initiatorIdentity : _responderIdentity;
var remote = _direction == AuthenticationDirection.Initiator
? _responderIdentity : _initiatorIdentity;
return new AuthenticationResult(AuthenticationRuling.Succeeded, data,
local, remote, _sessionKey);
}
static AuthenticationResult Failed()
=> new AuthenticationResult(AuthenticationRuling.Failed, null);
static string NormalizeOptionalIdentity(string identity)
=> identity == null ? null : PpapCryptography.NormalizeIdentity(identity);
void ClearPostConfirmationSecrets()
{
PpapCryptography.Clear(_initiatorFinishedKey);
PpapCryptography.Clear(_responderFinishedKey);
PpapCryptography.Clear(_transcriptHash);
PpapCryptography.Clear(_authenticationContext);
PpapCryptography.Clear(_initiatorMask);
PpapCryptography.Clear(_responderMask);
_initiatorFinishedKey = null;
_responderFinishedKey = null;
_transcriptHash = null;
_authenticationContext = null;
_initiatorMask = null;
_responderMask = null;
foreach (var message in _transcript)
PpapCryptography.Clear(message);
_transcript.Clear();
}
void ClearHandshakeSecrets(bool clearSessionKey)
{
PpapCryptography.Clear(_ephemeralPrivateKey);
PpapCryptography.Clear(_ephemeralSecret);
PpapCryptography.Clear(_initiatorIdentitySecret);
PpapCryptography.Clear(_responderIdentitySecret);
PpapCryptography.Clear(_initiatorFinishedKey);
PpapCryptography.Clear(_responderFinishedKey);
PpapCryptography.Clear(_transcriptHash);
PpapCryptography.Clear(_authenticationContext);
if (clearSessionKey)
PpapCryptography.Clear(_sessionKey);
_ephemeralPrivateKey = null;
_ephemeralSecret = null;
_initiatorIdentitySecret = null;
_responderIdentitySecret = null;
_initiatorFinishedKey = null;
_responderFinishedKey = null;
if (clearSessionKey)
_sessionKey = null;
foreach (var message in _transcript)
PpapCryptography.Clear(message);
_transcript.Clear();
}
public void Dispose()
{
lock (_sync)
{
if (_state == HandshakeState.Disposed)
return;
ClearHandshakeSecrets(clearSessionKey: true);
PpapCryptography.Clear(_initiatorMask);
PpapCryptography.Clear(_responderMask);
PpapCryptography.Clear(_transcriptHash);
PpapCryptography.Clear(_authenticationContext);
ClearRotationSecrets();
_state = HandshakeState.Disposed;
}
}
}
@@ -0,0 +1,85 @@
using Esiur.Core;
using System;
namespace Esiur.Security.Authority.Providers.Ppap;
public delegate PpapLocalIdentity PpapLocalIdentityResolver(
AuthenticationContext context);
/// <summary>
/// Fixed-suite PPAP ML-KEM-768 authentication provider.
/// </summary>
public sealed class PpapAuthenticationProvider : IAuthenticationProvider
{
public string DefaultName => PpapProtocol.Name;
public PpapLocalIdentity LocalIdentity { get; }
public IPpapRegistrationStore Registrations { get; }
/// <summary>
/// Admission control for password-based Argon2id work performed during live
/// authentication and protected rotation exchanges.
/// </summary>
public PpapPasswordDerivationLimiter PasswordDerivationLimiter { get; }
readonly PpapLocalIdentityResolver _localIdentityResolver;
public PpapAuthenticationProvider(PpapLocalIdentity localIdentity = null,
IPpapRegistrationStore registrations = null,
PpapPasswordDerivationLimiter passwordDerivationLimiter = null)
{
LocalIdentity = localIdentity;
Registrations = registrations ?? new InMemoryPpapRegistrationStore();
PasswordDerivationLimiter = passwordDerivationLimiter
?? PpapPasswordDerivationLimiter.Shared;
}
/// <summary>
/// Advanced constructor for hosts serving multiple domains or local identities.
/// The selected identity is resolved and snapshotted when a handler is created.
/// </summary>
public PpapAuthenticationProvider(IPpapRegistrationStore registrations,
PpapLocalIdentityResolver localIdentityResolver,
PpapPasswordDerivationLimiter passwordDerivationLimiter = null)
{
Registrations = registrations ?? throw new ArgumentNullException(nameof(registrations));
_localIdentityResolver = localIdentityResolver
?? throw new ArgumentNullException(nameof(localIdentityResolver));
PasswordDerivationLimiter = passwordDerivationLimiter
?? PpapPasswordDerivationLimiter.Shared;
}
internal PpapLocalIdentity ResolveLocalIdentity(AuthenticationContext context)
=> _localIdentityResolver == null
? LocalIdentity
: _localIdentityResolver(context);
internal byte[] DerivePrivateKey(PpapLocalIdentity identity, string domain,
byte[] nonce, PpapKdfProfile profile,
bool postAuthentication = false)
{
if (identity == null)
throw new ArgumentNullException(nameof(identity));
if (identity.Kind != PpapIdentityKind.PasswordDerived)
return identity.DerivePrivateKey(domain, nonce, profile);
using (var lease = PasswordDerivationLimiter.TryAcquire(postAuthentication))
{
if (lease == null)
throw new InvalidOperationException(
"PPAP password-derivation capacity is temporarily exhausted.");
return identity.DerivePrivateKey(domain, nonce, profile);
}
}
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
{
if (context == null)
throw new ArgumentNullException(nameof(context));
return new PpapAuthenticationHandler(this, context);
}
public AsyncReply<bool> Login(Session session) => new AsyncReply<bool>(true);
public AsyncReply<bool> Logout(Session session) => new AsyncReply<bool>(true);
}
@@ -0,0 +1,664 @@
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Digests;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Kems;
using Org.BouncyCastle.Crypto.Macs;
using Org.BouncyCastle.Crypto.Modes;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Utilities;
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace Esiur.Security.Authority.Providers.Ppap;
public static class PpapProtocol
{
public const string Name = "ppap-mlkem768-v1";
public const int WireVersion = 1;
public const int PublicKeyLength = 1184;
public const int PrivateKeyLength = 2400;
public const int CiphertextLength = 1088;
public const int KemSecretLength = 32;
public const int SeedLength = 64;
public const int HashLength = 32;
public const int FinishedTagLength = 32;
public const int IdentityMaskLength = 32;
public const int RegistrationNonceLength = 32;
public const int SessionKeyLength = 32;
public const int DescriptorPlaintextLength = 128;
public const int DescriptorKeyLength = 32;
public const int DescriptorNonceLength = 12;
public const int DescriptorTagLength = 16;
public const int ProtectedDescriptorLength =
DescriptorPlaintextLength + DescriptorTagLength;
public const int MaximumIdentityBytes = 512;
public const int MaximumDomainBytes = 512;
public const int MaximumPasswordBytes = 4096;
public const int MaximumWireMessageBytes = 8192;
}
internal static class PpapCryptography
{
static readonly MLKemParameters KemParameters = MLKemParameters.ml_kem_768;
static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true);
static readonly byte[] PasswordSeedLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/password-seed");
static readonly byte[] IdentityMaskLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/masked-identity");
static readonly byte[] DescriptorSaltLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/descriptor-salt");
static readonly byte[] DescriptorKeyLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/descriptor-key");
static readonly byte[] DescriptorNonceLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/descriptor-nonce");
static readonly byte[] DescriptorAadLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/descriptor-aad");
static readonly byte[] InitiatorDescriptorLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/initiator-registration");
static readonly byte[] ResponderDescriptorLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/responder-registration");
static readonly byte[] TranscriptLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/transcript");
static readonly byte[] KeyScheduleLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/key-schedule");
static readonly byte[] SessionKeyLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/session-key");
static readonly byte[] InitiatorFinishedKeyLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/initiator-finished-key");
static readonly byte[] ResponderFinishedKeyLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/responder-finished-key");
static readonly byte[] InitiatorFinishedLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/initiator-finished");
static readonly byte[] ResponderFinishedLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/responder-finished");
static readonly byte[] RotationProofLabel = Encoding.ASCII.GetBytes(
"esiur/ppap-mlkem768-v1/rotation-proof");
internal static string NormalizeIdentity(string identity)
{
if (string.IsNullOrWhiteSpace(identity))
throw new ArgumentException("An identity is required.", nameof(identity));
var normalized = identity.Normalize(NormalizationForm.FormC);
var encoded = EncodeUtf8(normalized, PpapProtocol.MaximumIdentityBytes, nameof(identity));
Clear(encoded);
return normalized;
}
internal static string NormalizeDomain(string domain)
{
var normalized = (domain ?? string.Empty).Normalize(NormalizationForm.FormC);
var encoded = EncodeUtf8(normalized, PpapProtocol.MaximumDomainBytes, nameof(domain));
Clear(encoded);
return normalized;
}
internal static byte[] EncodeUtf8(string value, int maximumBytes, string parameterName)
{
try
{
var encoded = StrictUtf8.GetBytes(value ?? string.Empty);
if (encoded.Length > maximumBytes)
{
Clear(encoded);
throw new ArgumentException("The UTF-8 value is too long.", parameterName);
}
return encoded;
}
catch (EncoderFallbackException ex)
{
throw new ArgumentException("The value contains invalid Unicode.", parameterName, ex);
}
}
internal static string DecodeUtf8(byte[] value, int maximumBytes, string parameterName)
{
if (value == null || value.Length > maximumBytes)
throw new ArgumentException("The UTF-8 value has an invalid length.", parameterName);
try
{
return StrictUtf8.GetString(value).Normalize(NormalizationForm.FormC);
}
catch (DecoderFallbackException ex)
{
throw new ArgumentException("The value is not valid UTF-8.", parameterName, ex);
}
}
internal static byte[] RandomBytes(int length)
{
if (length <= 0)
throw new ArgumentOutOfRangeException(nameof(length));
var value = new byte[length];
using (var random = RandomNumberGenerator.Create())
random.GetBytes(value);
return value;
}
internal static byte[] Hash(params byte[][] values)
{
var digest = new Sha3Digest(256);
foreach (var value in values)
{
if (value != null && value.Length != 0)
digest.BlockUpdate(value, 0, value.Length);
}
var output = new byte[PpapProtocol.HashLength];
digest.DoFinal(output, 0);
return output;
}
internal static byte[] HashFramed(params byte[][] values)
{
var encoded = EncodeFrames(values);
try
{
return Hash(encoded);
}
finally
{
Clear(encoded);
}
}
internal static byte[] MaskIdentity(string domain, string identity, byte[] mask,
byte[] maskKey)
{
if (mask == null || mask.Length != PpapProtocol.IdentityMaskLength)
throw new ArgumentException("The identity mask has an invalid length.", nameof(mask));
ValidateKemSecret(maskKey, nameof(maskKey));
var domainBytes = EncodeUtf8(NormalizeDomain(domain),
PpapProtocol.MaximumDomainBytes, nameof(domain));
var identityBytes = EncodeUtf8(NormalizeIdentity(identity),
PpapProtocol.MaximumIdentityBytes, nameof(identity));
try
{
var input = EncodeFrames(IdentityMaskLabel, domainBytes, identityBytes, mask);
try
{
return ComputeMac(maskKey, input);
}
finally
{
Clear(input);
}
}
finally
{
Clear(domainBytes);
Clear(identityBytes);
}
}
// Protected descriptor v1 is:
// uint32be descriptorLength || canonicalDescriptor || zeroPadding
// padded to DescriptorPlaintextLength, followed by a 128-bit GCM tag.
// HKDF-SHA3 derives a role/domain/message-specific AES-256 key and 96-bit
// nonce from the fresh ephemeral ML-KEM secret; the nonce is not sent.
// Protocol implementations MUST encrypt at most one descriptor for each
// role under a given ephemeral secret. ResponderProof therefore reuses its
// single protected field when constructing the transcript core and packet.
internal static byte[] ProtectRegistrationDescriptor(string domain,
PpapIdentityRole role, byte[] ephemeralSecret, byte[] descriptor)
{
ValidateKemSecret(ephemeralSecret, nameof(ephemeralSecret));
if (descriptor == null || descriptor.Length == 0
|| descriptor.Length > PpapProtocol.DescriptorPlaintextLength - 4)
throw new ArgumentException("The registration descriptor has an invalid length.",
nameof(descriptor));
var framed = EncodeFrames(descriptor);
var plaintext = new byte[PpapProtocol.DescriptorPlaintextLength];
var output = new byte[PpapProtocol.ProtectedDescriptorLength];
byte[] key = null;
byte[] nonce = null;
byte[] aad = null;
try
{
Buffer.BlockCopy(framed, 0, plaintext, 0, framed.Length);
DeriveDescriptorProtection(domain, role, ephemeralSecret,
out key, out nonce, out aad);
var cipher = new GcmBlockCipher(AesUtilities.CreateEngine());
cipher.Init(true, new AeadParameters(new KeyParameter(key),
PpapProtocol.DescriptorTagLength * 8, nonce, aad));
var written = cipher.ProcessBytes(plaintext, 0, plaintext.Length,
output, 0);
written += cipher.DoFinal(output, written);
if (written != output.Length)
throw new CryptographicException(
"AES-GCM produced an invalid protected descriptor length.");
return output;
}
catch (CryptoException ex)
{
Clear(output);
throw new CryptographicException(
"Registration descriptor protection failed.", ex);
}
catch
{
Clear(output);
throw;
}
finally
{
Clear(framed);
Clear(plaintext);
Clear(key);
Clear(nonce);
Clear(aad);
}
}
internal static byte[] UnprotectRegistrationDescriptor(string domain,
PpapIdentityRole role, byte[] ephemeralSecret, byte[] protectedDescriptor)
{
ValidateKemSecret(ephemeralSecret, nameof(ephemeralSecret));
if (protectedDescriptor == null
|| protectedDescriptor.Length != PpapProtocol.ProtectedDescriptorLength)
throw new InvalidDataException(
"The protected registration descriptor has an invalid length.");
var plaintext = new byte[PpapProtocol.DescriptorPlaintextLength];
byte[] key = null;
byte[] nonce = null;
byte[] aad = null;
try
{
DeriveDescriptorProtection(domain, role, ephemeralSecret,
out key, out nonce, out aad);
var cipher = new GcmBlockCipher(AesUtilities.CreateEngine());
cipher.Init(false, new AeadParameters(new KeyParameter(key),
PpapProtocol.DescriptorTagLength * 8, nonce, aad));
var written = cipher.ProcessBytes(protectedDescriptor, 0,
protectedDescriptor.Length, plaintext, 0);
written += cipher.DoFinal(plaintext, written);
if (written != plaintext.Length)
throw new InvalidDataException(
"AES-GCM produced an invalid registration descriptor length.");
var offset = 0;
var descriptorLength = ReadInt32(plaintext, ref offset);
if (descriptorLength == 0
|| descriptorLength > PpapProtocol.DescriptorPlaintextLength - offset)
throw new InvalidDataException(
"The protected registration descriptor is malformed.");
var descriptor = new byte[descriptorLength];
Buffer.BlockCopy(plaintext, offset, descriptor, 0, descriptorLength);
offset += descriptorLength;
for (var i = offset; i < plaintext.Length; i++)
{
if (plaintext[i] != 0)
{
Clear(descriptor);
throw new InvalidDataException(
"The protected registration descriptor is not canonical.");
}
}
return descriptor;
}
catch (CryptoException ex)
{
throw new InvalidDataException(
"Protected registration descriptor authentication failed.", ex);
}
finally
{
Clear(plaintext);
Clear(key);
Clear(nonce);
Clear(aad);
}
}
static void DeriveDescriptorProtection(string domain, PpapIdentityRole role,
byte[] ephemeralSecret, out byte[] key, out byte[] nonce, out byte[] aad)
{
var roleLabel = GetDescriptorRoleLabel(role, out var messageType);
var domainBytes = EncodeUtf8(NormalizeDomain(domain),
PpapProtocol.MaximumDomainBytes, nameof(domain));
var binding = new[]
{
(byte)PpapProtocol.WireVersion,
(byte)role,
(byte)messageType,
};
var salt = HashFramed(DescriptorSaltLabel, roleLabel, domainBytes, binding);
key = null;
nonce = null;
aad = null;
try
{
key = Expand(ephemeralSecret, salt,
EncodeFrames(DescriptorKeyLabel, roleLabel, domainBytes, binding),
PpapProtocol.DescriptorKeyLength);
nonce = Expand(ephemeralSecret, salt,
EncodeFrames(DescriptorNonceLabel, roleLabel, domainBytes, binding),
PpapProtocol.DescriptorNonceLength);
aad = EncodeFrames(DescriptorAadLabel, roleLabel, domainBytes, binding);
}
catch
{
Clear(key);
Clear(nonce);
Clear(aad);
key = null;
nonce = null;
aad = null;
throw;
}
finally
{
Clear(domainBytes);
Clear(binding);
Clear(salt);
}
}
static byte[] GetDescriptorRoleLabel(PpapIdentityRole role,
out PpapMessageType messageType)
{
if (role == PpapIdentityRole.Initiator)
{
messageType = PpapMessageType.ResponderProof;
return InitiatorDescriptorLabel;
}
if (role == PpapIdentityRole.Responder)
{
messageType = PpapMessageType.InitiatorProof;
return ResponderDescriptorLabel;
}
throw new ArgumentOutOfRangeException(nameof(role));
}
internal static byte[] DerivePasswordPrivateKey(string domain, string identity,
byte[] password, byte[] nonce, PpapKdfProfile profile)
{
if (password == null || password.Length == 0
|| password.Length > PpapProtocol.MaximumPasswordBytes)
throw new ArgumentException("The password has an invalid length.", nameof(password));
if (nonce == null || nonce.Length != PpapProtocol.RegistrationNonceLength)
throw new ArgumentException("The registration nonce has an invalid length.", nameof(nonce));
if (profile == null)
throw new ArgumentNullException(nameof(profile));
var identityBytes = EncodeUtf8(NormalizeIdentity(identity),
PpapProtocol.MaximumIdentityBytes, nameof(identity));
var domainBytes = EncodeUtf8(NormalizeDomain(domain),
PpapProtocol.MaximumDomainBytes, nameof(domain));
var input = EncodeFrames(PasswordSeedLabel, domainBytes, identityBytes,
password, nonce);
var seed = new byte[PpapProtocol.SeedLength];
try
{
var parameters = new Argon2Parameters.Builder(Argon2Parameters.Argon2id)
.WithVersion(profile.Version)
.WithSalt((byte[])nonce.Clone())
.WithMemoryAsKB(profile.MemoryKiB)
.WithIterations(profile.Iterations)
.WithParallelism(profile.Parallelism)
.Build();
var argon2 = new Argon2BytesGenerator();
argon2.Init(parameters);
argon2.GenerateBytes(input, seed);
var privateKey = MLKemPrivateKeyParameters.FromSeed(KemParameters, seed);
return privateKey.GetEncoded();
}
finally
{
Clear(identityBytes);
Clear(domainBytes);
Clear(input);
Clear(seed);
}
}
internal static void GenerateKeyPair(out byte[] privateKey, out byte[] publicKey)
{
var seed = RandomBytes(PpapProtocol.SeedLength);
try
{
var parameters = MLKemPrivateKeyParameters.FromSeed(KemParameters, seed);
privateKey = parameters.GetEncoded();
publicKey = parameters.GetPublicKeyEncoded();
}
finally
{
Clear(seed);
}
}
internal static byte[] GetPublicKey(byte[] privateKey)
{
ValidatePrivateKey(privateKey);
return MLKemPrivateKeyParameters.FromEncoding(KemParameters, privateKey)
.GetPublicKeyEncoded();
}
internal static void ValidatePrivateKey(byte[] privateKey)
{
if (privateKey == null || privateKey.Length != PpapProtocol.PrivateKeyLength)
throw new ArgumentException("The ML-KEM-768 private key has an invalid length.", nameof(privateKey));
MLKemPrivateKeyParameters.FromEncoding(KemParameters, privateKey);
}
internal static void ValidatePublicKey(byte[] publicKey)
{
if (publicKey == null || publicKey.Length != PpapProtocol.PublicKeyLength)
throw new ArgumentException("The ML-KEM-768 public key has an invalid length.", nameof(publicKey));
MLKemPublicKeyParameters.FromEncoding(KemParameters, publicKey);
}
internal static void Encapsulate(byte[] publicKey, out byte[] ciphertext,
out byte[] secret)
{
ValidatePublicKey(publicKey);
var encapsulator = new MLKemEncapsulator(KemParameters);
encapsulator.Init(MLKemPublicKeyParameters.FromEncoding(KemParameters, publicKey));
ciphertext = new byte[encapsulator.EncapsulationLength];
secret = new byte[encapsulator.SecretLength];
try
{
encapsulator.Encapsulate(ciphertext, 0, ciphertext.Length,
secret, 0, secret.Length);
}
catch
{
Clear(ciphertext);
Clear(secret);
ciphertext = null;
secret = null;
throw;
}
}
internal static byte[] Decapsulate(byte[] privateKey, byte[] ciphertext)
{
ValidatePrivateKey(privateKey);
if (ciphertext == null || ciphertext.Length != PpapProtocol.CiphertextLength)
throw new ArgumentException("The ML-KEM-768 ciphertext has an invalid length.", nameof(ciphertext));
var decapsulator = new MLKemDecapsulator(KemParameters);
decapsulator.Init(MLKemPrivateKeyParameters.FromEncoding(KemParameters, privateKey));
var secret = new byte[decapsulator.SecretLength];
decapsulator.Decapsulate(ciphertext, 0, ciphertext.Length,
secret, 0, secret.Length);
return secret;
}
internal static byte[] ComputeTranscriptHash(IList<byte[]> messages,
byte[] authenticationContext)
{
if (messages == null)
throw new ArgumentNullException(nameof(messages));
var frames = new byte[messages.Count + 2][];
frames[0] = TranscriptLabel;
frames[1] = authenticationContext ?? Array.Empty<byte>();
for (var i = 0; i < messages.Count; i++)
frames[i + 2] = messages[i];
return HashFramed(frames);
}
internal static void DeriveHandshakeKeys(byte[] ephemeralSecret,
byte[] initiatorIdentitySecret, byte[] responderIdentitySecret,
byte[] transcriptHash, byte[] authenticationContext,
out byte[] sessionKey, out byte[] initiatorFinishedKey,
out byte[] responderFinishedKey)
{
ValidateKemSecret(ephemeralSecret, nameof(ephemeralSecret));
ValidateOptionalKemSecret(initiatorIdentitySecret, nameof(initiatorIdentitySecret));
ValidateOptionalKemSecret(responderIdentitySecret, nameof(responderIdentitySecret));
if (transcriptHash == null || transcriptHash.Length != PpapProtocol.HashLength)
throw new ArgumentException("The transcript hash has an invalid length.", nameof(transcriptHash));
var input = EncodeFrames(KeyScheduleLabel, ephemeralSecret,
initiatorIdentitySecret ?? Array.Empty<byte>(),
responderIdentitySecret ?? Array.Empty<byte>());
try
{
sessionKey = Expand(input, transcriptHash,
EncodeFrames(SessionKeyLabel, authenticationContext),
PpapProtocol.SessionKeyLength);
initiatorFinishedKey = Expand(input, transcriptHash,
EncodeFrames(InitiatorFinishedKeyLabel, authenticationContext),
PpapProtocol.HashLength);
responderFinishedKey = Expand(input, transcriptHash,
EncodeFrames(ResponderFinishedKeyLabel, authenticationContext),
PpapProtocol.HashLength);
}
finally
{
Clear(input);
}
}
static byte[] Expand(byte[] input, byte[] salt, byte[] info, int length)
{
try
{
var hkdf = new HkdfBytesGenerator(new Sha3Digest(256));
hkdf.Init(new HkdfParameters(input, salt, info));
var output = new byte[length];
hkdf.GenerateBytes(output, 0, output.Length);
return output;
}
finally
{
Clear(info);
}
}
internal static byte[] ComputeFinished(bool initiator, byte[] finishedKey,
byte[] transcriptHash)
{
if (finishedKey == null || finishedKey.Length != PpapProtocol.HashLength)
throw new ArgumentException("The Finished key has an invalid length.", nameof(finishedKey));
if (transcriptHash == null || transcriptHash.Length != PpapProtocol.HashLength)
throw new ArgumentException("The transcript hash has an invalid length.", nameof(transcriptHash));
var input = EncodeFrames(initiator ? InitiatorFinishedLabel : ResponderFinishedLabel,
transcriptHash);
try
{
return ComputeMac(finishedKey, input);
}
finally
{
Clear(input);
}
}
internal static byte[] ComputeMac(byte[] key, byte[] input)
{
if (key == null || key.Length == 0)
throw new ArgumentException("A MAC key is required.", nameof(key));
var hmac = new HMac(new Sha3Digest(256));
hmac.Init(new KeyParameter(key));
hmac.BlockUpdate(input, 0, input.Length);
var output = new byte[hmac.GetMacSize()];
hmac.DoFinal(output, 0);
return output;
}
internal static byte[] ComputeRotationProof(byte[] challengeSecret,
byte[] sessionKey, byte[] offer, byte[] challenge)
{
ValidateKemSecret(challengeSecret, nameof(challengeSecret));
if (sessionKey == null || sessionKey.Length != PpapProtocol.SessionKeyLength)
throw new ArgumentException("The session key has an invalid length.", nameof(sessionKey));
var context = HashFramed(RotationProofLabel, sessionKey, offer, challenge);
try
{
return ComputeMac(challengeSecret, context);
}
finally
{
Clear(context);
}
}
internal static bool FixedTimeEquals(byte[] left, byte[] right)
{
return left != null && right != null && left.Length == right.Length
&& Arrays.FixedTimeEquals(left, right);
}
internal static byte[] EncodeFrames(params byte[][] values)
{
using (var output = new MemoryStream())
{
foreach (var value in values)
{
var field = value ?? Array.Empty<byte>();
WriteInt32(output, field.Length);
output.Write(field, 0, field.Length);
}
return output.ToArray();
}
}
internal static void WriteInt32(Stream output, int value)
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(value));
output.WriteByte((byte)(value >> 24));
output.WriteByte((byte)(value >> 16));
output.WriteByte((byte)(value >> 8));
output.WriteByte((byte)value);
}
internal static int ReadInt32(byte[] input, ref int offset)
{
if (input == null || offset < 0 || input.Length - offset < 4)
throw new InvalidDataException("Truncated PPAP integer.");
var value = (input[offset] << 24)
| (input[offset + 1] << 16)
| (input[offset + 2] << 8)
| input[offset + 3];
offset += 4;
if (value < 0)
throw new InvalidDataException("Negative PPAP length.");
return value;
}
static void ValidateKemSecret(byte[] value, string parameterName)
{
if (value == null || value.Length != PpapProtocol.KemSecretLength)
throw new ArgumentException("The KEM secret has an invalid length.", parameterName);
}
static void ValidateOptionalKemSecret(byte[] value, string parameterName)
{
if (value != null && value.Length != PpapProtocol.KemSecretLength)
throw new ArgumentException("The KEM secret has an invalid length.", parameterName);
}
internal static void Clear(byte[] value)
{
if (value != null)
Array.Clear(value, 0, value.Length);
}
}
@@ -0,0 +1,453 @@
using System;
using System.Collections.Concurrent;
using System.Text;
namespace Esiur.Security.Authority.Providers.Ppap;
/// <summary>
/// The kind of long-lived identity authenticated by PPAP ML-KEM.
/// </summary>
public enum PpapIdentityKind : byte
{
PasswordDerived = 1,
StaticMlKem = 2,
}
/// <summary>
/// Immutable Argon2id parameters used to turn a password into an ML-KEM seed.
/// </summary>
public sealed class PpapKdfProfile : IEquatable<PpapKdfProfile>
{
public const int Argon2Version13 = 0x13;
public static PpapKdfProfile Default { get; } =
new PpapKdfProfile(Argon2Version13, 32 * 1024, 3, 1);
public int Version { get; }
public int MemoryKiB { get; }
public int Iterations { get; }
public int Parallelism { get; }
public PpapKdfProfile(int version, int memoryKiB, int iterations, int parallelism)
{
if (version != Argon2Version13)
throw new ArgumentOutOfRangeException(nameof(version), "Only Argon2 version 1.3 is supported.");
if (memoryKiB < 8 * 1024 || memoryKiB > 256 * 1024)
throw new ArgumentOutOfRangeException(nameof(memoryKiB));
if (iterations < 1 || iterations > 10)
throw new ArgumentOutOfRangeException(nameof(iterations));
if (parallelism < 1 || parallelism > 16)
throw new ArgumentOutOfRangeException(nameof(parallelism));
if (memoryKiB < parallelism * 8)
throw new ArgumentOutOfRangeException(nameof(memoryKiB));
Version = version;
MemoryKiB = memoryKiB;
Iterations = iterations;
Parallelism = parallelism;
}
public bool Equals(PpapKdfProfile other)
=> other != null
&& Version == other.Version
&& MemoryKiB == other.MemoryKiB
&& Iterations == other.Iterations
&& Parallelism == other.Parallelism;
public override bool Equals(object obj) => Equals(obj as PpapKdfProfile);
public override int GetHashCode()
{
unchecked
{
var hash = Version;
hash = (hash * 397) ^ MemoryKiB;
hash = (hash * 397) ^ Iterations;
return (hash * 397) ^ Parallelism;
}
}
}
/// <summary>
/// Local PPAP identity. Secret material is cloned and is never exposed by a property.
/// </summary>
public sealed class PpapLocalIdentity : IDisposable
{
readonly byte[] _secret;
bool _disposed;
public string Identity { get; }
public PpapIdentityKind Kind { get; }
public PpapKdfProfile KdfProfile { get; }
PpapLocalIdentity(string identity, PpapIdentityKind kind, byte[] secret,
PpapKdfProfile kdfProfile)
{
Identity = PpapCryptography.NormalizeIdentity(identity);
Kind = kind;
KdfProfile = kdfProfile;
_secret = (byte[])secret.Clone();
}
public static PpapLocalIdentity FromPassword(string identity, string password,
PpapKdfProfile kdfProfile = null)
{
if (password == null)
throw new ArgumentNullException(nameof(password));
var bytes = Encoding.UTF8.GetBytes(password.Normalize(NormalizationForm.FormC));
try
{
return FromPassword(identity, bytes, kdfProfile);
}
finally
{
PpapCryptography.Clear(bytes);
}
}
public static PpapLocalIdentity FromPassword(string identity, byte[] password,
PpapKdfProfile kdfProfile = null)
{
if (password == null || password.Length == 0)
throw new ArgumentException("A password is required.", nameof(password));
if (password.Length > PpapProtocol.MaximumPasswordBytes)
throw new ArgumentException("The password is too long.", nameof(password));
return new PpapLocalIdentity(identity, PpapIdentityKind.PasswordDerived,
password, kdfProfile ?? PpapKdfProfile.Default);
}
public static PpapLocalIdentity FromStaticKey(string identity, byte[] privateKey)
{
PpapCryptography.ValidatePrivateKey(privateKey);
return new PpapLocalIdentity(identity, PpapIdentityKind.StaticMlKem,
privateKey, null);
}
/// <summary>
/// Creates a new static ML-KEM-768 identity with a cryptographically random key.
/// </summary>
public static PpapLocalIdentity CreateStatic(string identity)
{
PpapCryptography.GenerateKeyPair(out var privateKey, out var publicKey);
try
{
return FromStaticKey(identity, privateKey);
}
finally
{
PpapCryptography.Clear(privateKey);
PpapCryptography.Clear(publicKey);
}
}
/// <summary>
/// Exports a copy of a static ML-KEM private key for durable secure storage.
/// The caller owns the returned buffer and should erase it after persisting it.
/// Password-derived identities cannot be exported.
/// </summary>
public byte[] ExportStaticPrivateKey()
{
ThrowIfDisposed();
if (Kind != PpapIdentityKind.StaticMlKem)
throw new InvalidOperationException(
"Password-derived PPAP identities cannot export private key material.");
return (byte[])_secret.Clone();
}
internal byte[] DerivePrivateKey(string domain, byte[] nonce,
PpapKdfProfile profile)
{
ThrowIfDisposed();
if (Kind == PpapIdentityKind.StaticMlKem)
{
if (nonce != null && nonce.Length != 0)
throw new InvalidOperationException("A static identity cannot have a KDF nonce.");
return (byte[])_secret.Clone();
}
if (profile == null || !KdfProfile.Equals(profile))
throw new InvalidOperationException("The registration KDF profile is not accepted by the local identity.");
return PpapCryptography.DerivePasswordPrivateKey(domain, Identity,
_secret, nonce, profile);
}
internal byte[] GetStaticPublicKey()
{
ThrowIfDisposed();
if (Kind != PpapIdentityKind.StaticMlKem)
throw new InvalidOperationException("The identity is not backed by a static key.");
return PpapCryptography.GetPublicKey(_secret);
}
void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(nameof(PpapLocalIdentity));
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
PpapCryptography.Clear(_secret);
}
}
/// <summary>
/// Immutable verifier-side registration data for one PPAP identity.
/// </summary>
public sealed class PpapRegistrationRecord
{
readonly byte[] _nonce;
readonly byte[] _encapsulationKey;
public long Version { get; }
public string Identity { get; }
public PpapIdentityKind Kind { get; }
public PpapKdfProfile KdfProfile { get; }
public byte[] Nonce => (byte[])_nonce.Clone();
public byte[] EncapsulationKey => (byte[])_encapsulationKey.Clone();
internal byte[] NonceBytes => (byte[])_nonce.Clone();
internal byte[] EncapsulationKeyBytes => (byte[])_encapsulationKey.Clone();
public PpapRegistrationRecord(long version, string identity, PpapIdentityKind kind,
byte[] nonce, byte[] encapsulationKey, PpapKdfProfile kdfProfile = null)
{
if (version < 1)
throw new ArgumentOutOfRangeException(nameof(version));
if (!Enum.IsDefined(typeof(PpapIdentityKind), kind))
throw new ArgumentOutOfRangeException(nameof(kind));
Identity = PpapCryptography.NormalizeIdentity(identity);
Version = version;
Kind = kind;
PpapCryptography.ValidatePublicKey(encapsulationKey);
if (kind == PpapIdentityKind.PasswordDerived)
{
if (nonce == null || nonce.Length != PpapProtocol.RegistrationNonceLength)
throw new ArgumentException("A password registration requires a 32-byte nonce.", nameof(nonce));
KdfProfile = kdfProfile ?? throw new ArgumentNullException(nameof(kdfProfile));
_nonce = (byte[])nonce.Clone();
}
else
{
if (nonce != null && nonce.Length != 0)
throw new ArgumentException("A static-key registration cannot contain a KDF nonce.", nameof(nonce));
if (kdfProfile != null)
throw new ArgumentException("A static-key registration cannot contain a KDF profile.", nameof(kdfProfile));
_nonce = Array.Empty<byte>();
}
_encapsulationKey = (byte[])encapsulationKey.Clone();
}
public static PpapRegistrationRecord FromPassword(string domain, string identity,
string password,
long version = 1, byte[] nonce = null, PpapKdfProfile kdfProfile = null)
{
using (var local = PpapLocalIdentity.FromPassword(identity, password, kdfProfile))
return FromLocalIdentity(domain, local, version, nonce);
}
public static PpapRegistrationRecord FromLocalIdentity(string domain,
PpapLocalIdentity identity,
long version = 1, byte[] nonce = null)
{
if (identity == null)
throw new ArgumentNullException(nameof(identity));
if (identity.Kind == PpapIdentityKind.StaticMlKem)
return new PpapRegistrationRecord(version, identity.Identity, identity.Kind,
Array.Empty<byte>(), identity.GetStaticPublicKey());
var registrationNonce = nonce == null
? PpapCryptography.RandomBytes(PpapProtocol.RegistrationNonceLength)
: (byte[])nonce.Clone();
byte[] privateKey = null;
byte[] publicKey = null;
try
{
privateKey = identity.DerivePrivateKey(domain, registrationNonce,
identity.KdfProfile);
publicKey = PpapCryptography.GetPublicKey(privateKey);
return new PpapRegistrationRecord(version, identity.Identity, identity.Kind,
registrationNonce, publicKey, identity.KdfProfile);
}
finally
{
PpapCryptography.Clear(privateKey);
PpapCryptography.Clear(publicKey);
PpapCryptography.Clear(registrationNonce);
}
}
}
public interface IPpapRegistrationStore
{
PpapRegistrationRecord Get(string domain, string identity);
/// <summary>
/// Resolves a per-handshake, keyed identity mask. Implementations must not
/// persist <paramref name="maskKey"/> or use it as a stable lookup token.
/// </summary>
PpapRegistrationRecord ResolveMasked(string domain, byte[] mask,
byte[] maskKey, byte[] maskedIdentity);
/// <summary>
/// Atomically replaces exactly the registration identified by
/// <paramref name="domain"/>, <paramref name="identity"/>, and
/// <paramref name="expectedVersion"/>. Implementations must perform a
/// linearizable compare-and-swap, preserve identity, kind, and KDF policy,
/// reject reused nonce or encapsulation-key material, and return false
/// without mutation when any precondition fails. A database implementation
/// must use a conditional update/transaction, not a separate read then write.
/// </summary>
bool TryRotate(string domain, string identity, long expectedVersion,
PpapRegistrationRecord replacement);
}
/// <summary>
/// Thread-safe in-memory registration store intended for shortcuts, tests, and small deployments.
/// Masked resolution deliberately scans all records in a domain.
/// </summary>
public sealed class InMemoryPpapRegistrationStore : IPpapRegistrationStore
{
struct RegistrationKey : IEquatable<RegistrationKey>
{
public readonly string Domain;
public readonly string Identity;
public RegistrationKey(string domain, string identity)
{
Domain = domain;
Identity = identity;
}
public bool Equals(RegistrationKey other)
=> string.Equals(Domain, other.Domain, StringComparison.Ordinal)
&& string.Equals(Identity, other.Identity, StringComparison.Ordinal);
public override bool Equals(object obj)
=> obj is RegistrationKey && Equals((RegistrationKey)obj);
public override int GetHashCode()
{
unchecked
{
return ((Domain?.GetHashCode() ?? 0) * 397)
^ (Identity?.GetHashCode() ?? 0);
}
}
}
readonly ConcurrentDictionary<RegistrationKey, PpapRegistrationRecord> _records = new();
static RegistrationKey MakeKey(string domain, string identity)
=> new RegistrationKey(PpapCryptography.NormalizeDomain(domain),
PpapCryptography.NormalizeIdentity(identity));
public bool TryAdd(string domain, PpapRegistrationRecord record)
{
if (record == null)
throw new ArgumentNullException(nameof(record));
return _records.TryAdd(MakeKey(domain, record.Identity), record);
}
public void AddOrUpdate(string domain, PpapRegistrationRecord record)
{
if (record == null)
throw new ArgumentNullException(nameof(record));
var key = MakeKey(domain, record.Identity);
_records.AddOrUpdate(key, record, (_, current) =>
{
if (RecordsEqual(current, record))
return current;
throw new InvalidOperationException(
"An existing PPAP registration can only be changed through TryRotate.");
});
}
public PpapRegistrationRecord Get(string domain, string identity)
{
_records.TryGetValue(MakeKey(domain, identity), out var record);
return record;
}
public PpapRegistrationRecord ResolveMasked(string domain, byte[] mask,
byte[] maskKey, byte[] maskedIdentity)
{
if (mask == null || mask.Length != PpapProtocol.IdentityMaskLength)
return null;
if (maskedIdentity == null || maskedIdentity.Length != PpapProtocol.HashLength)
return null;
if (maskKey == null || maskKey.Length != PpapProtocol.KemSecretLength)
return null;
var normalizedDomain = PpapCryptography.NormalizeDomain(domain);
PpapRegistrationRecord match = null;
var ambiguous = false;
foreach (var pair in _records)
{
if (!string.Equals(pair.Key.Domain, normalizedDomain, StringComparison.Ordinal))
continue;
var candidate = PpapCryptography.MaskIdentity(
normalizedDomain, pair.Value.Identity, mask, maskKey);
var equal = PpapCryptography.FixedTimeEquals(candidate, maskedIdentity);
PpapCryptography.Clear(candidate);
if (!equal)
continue;
if (match != null)
ambiguous = true;
match = pair.Value;
}
return ambiguous ? null : match;
}
public bool TryRotate(string domain, string identity, long expectedVersion,
PpapRegistrationRecord replacement)
{
if (replacement == null)
throw new ArgumentNullException(nameof(replacement));
var key = MakeKey(domain, identity);
if (!string.Equals(key.Identity, replacement.Identity, StringComparison.Ordinal)
|| replacement.Version != expectedVersion + 1)
return false;
if (!_records.TryGetValue(key, out var current)
|| current.Version != expectedVersion
|| current.Kind != PpapIdentityKind.PasswordDerived
|| replacement.Kind != PpapIdentityKind.PasswordDerived)
return false;
if (!current.KdfProfile.Equals(replacement.KdfProfile)
|| PpapCryptography.FixedTimeEquals(current.NonceBytes,
replacement.NonceBytes)
|| PpapCryptography.FixedTimeEquals(current.EncapsulationKeyBytes,
replacement.EncapsulationKeyBytes))
return false;
return _records.TryUpdate(key, replacement, current);
}
static bool RecordsEqual(PpapRegistrationRecord left,
PpapRegistrationRecord right)
=> left.Version == right.Version
&& left.Kind == right.Kind
&& string.Equals(left.Identity, right.Identity, StringComparison.Ordinal)
&& Equals(left.KdfProfile, right.KdfProfile)
&& PpapCryptography.FixedTimeEquals(left.NonceBytes, right.NonceBytes)
&& PpapCryptography.FixedTimeEquals(left.EncapsulationKeyBytes,
right.EncapsulationKeyBytes);
}
@@ -0,0 +1,91 @@
using System;
using System.Threading;
namespace Esiur.Security.Authority.Providers.Ppap;
/// <summary>
/// Bounds concurrent Argon2id work performed by PPAP authentication handlers.
/// Saturated limiters reject new derivations instead of queuing socket threads.
/// </summary>
/// <remarks>
/// This bounds authentication-handler concurrency, not registration provisioning.
/// Operators must also choose KDF memory profiles appropriate for the host because
/// slots are not weighted by <see cref="PpapKdfProfile.MemoryKiB"/>.
/// </remarks>
public sealed class PpapPasswordDerivationLimiter
{
sealed class Lease : IDisposable
{
PpapPasswordDerivationLimiter _owner;
public Lease(PpapPasswordDerivationLimiter owner)
{
_owner = owner;
}
public void Dispose()
{
Interlocked.Exchange(ref _owner, null)?.Release();
}
}
readonly object _sync = new object();
int _active;
/// <summary>
/// Process-wide default shared by PPAP providers. Its upper bound limits the
/// default Argon2id working set to four simultaneous 32-MiB derivations.
/// </summary>
public static PpapPasswordDerivationLimiter Shared { get; } =
new PpapPasswordDerivationLimiter(
Math.Max(1, Math.Min(Environment.ProcessorCount, 4)),
Environment.ProcessorCount > 1 ? 1 : 0);
public int MaximumConcurrency { get; }
/// <summary>
/// Slots unavailable to unauthenticated handshakes but available to the encrypted
/// post-authentication rotation phase, preventing pre-authentication starvation.
/// </summary>
public int ReservedPostAuthenticationSlots { get; }
public PpapPasswordDerivationLimiter(
int maximumConcurrency,
int reservedPostAuthenticationSlots = 0)
{
if (maximumConcurrency < 1)
throw new ArgumentOutOfRangeException(nameof(maximumConcurrency));
if (reservedPostAuthenticationSlots < 0
|| reservedPostAuthenticationSlots >= maximumConcurrency)
throw new ArgumentOutOfRangeException(
nameof(reservedPostAuthenticationSlots));
MaximumConcurrency = maximumConcurrency;
ReservedPostAuthenticationSlots = reservedPostAuthenticationSlots;
}
internal IDisposable TryAcquire(bool postAuthentication)
{
lock (_sync)
{
var limit = postAuthentication
? MaximumConcurrency
: MaximumConcurrency - ReservedPostAuthenticationSlots;
if (_active >= limit)
return null;
_active++;
return new Lease(this);
}
}
void Release()
{
lock (_sync)
{
if (_active < 1)
throw new InvalidOperationException("PPAP derivation limiter underflow.");
_active--;
}
}
}
@@ -0,0 +1,235 @@
using System;
using System.IO;
namespace Esiur.Security.Authority.Providers.Ppap;
internal static partial class PpapWire
{
internal sealed class RotationOffer
{
public PpapIdentityRole Role;
public string Identity;
public long ExpectedVersion;
public byte[] Nonce;
public byte[] EncapsulationKey;
public PpapKdfProfile KdfProfile;
}
internal sealed class RotationChallenge
{
public PpapIdentityRole Role;
public byte[] Ciphertext;
}
internal sealed class RotationProof
{
public PpapIdentityRole Role;
public byte[] Proof;
}
internal static byte[] EncodeRotationStart(PpapIdentityRole role)
=> Encode(PpapMessageType.RotationStart,
payload => payload.WriteByte(ValidateRole(role)));
internal static PpapIdentityRole DecodeRotationStart(object data)
=> DecodeRoleOnly(data, PpapMessageType.RotationStart);
internal static byte[] EncodeRotationOffer(PpapIdentityRole role, string identity,
long expectedVersion, byte[] nonce, byte[] encapsulationKey,
PpapKdfProfile kdfProfile)
{
ValidateRole(role);
if (expectedVersion < 1 || expectedVersion == long.MaxValue)
throw new InvalidDataException("Invalid rotation version.");
ValidateExact(nonce, PpapProtocol.RegistrationNonceLength, nameof(nonce));
PpapCryptography.ValidatePublicKey(encapsulationKey);
if (kdfProfile == null)
throw new ArgumentNullException(nameof(kdfProfile));
var identityBytes = PpapCryptography.EncodeUtf8(
PpapCryptography.NormalizeIdentity(identity),
PpapProtocol.MaximumIdentityBytes, nameof(identity));
var descriptor = EncodeDescriptor(new PpapRegistrationDescriptor(
PpapIdentityKind.PasswordDerived, expectedVersion + 1, nonce, kdfProfile));
try
{
return Encode(PpapMessageType.RotationOffer, payload =>
{
payload.WriteByte((byte)role);
WriteField(payload, identityBytes);
WriteInt64(payload, expectedVersion);
WriteField(payload, encapsulationKey);
WriteField(payload, descriptor);
});
}
finally
{
PpapCryptography.Clear(identityBytes);
PpapCryptography.Clear(descriptor);
}
}
internal static RotationOffer DecodeRotationOffer(object data)
{
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.RotationOffer);
var offset = 0;
var role = ReadRole(payload, ref offset);
var identityBytes = ReadField(payload, ref offset,
PpapProtocol.MaximumIdentityBytes);
var expectedVersion = ReadInt64(payload, ref offset);
var key = ReadField(payload, ref offset, PpapProtocol.PublicKeyLength);
var descriptorBytes = ReadField(payload, ref offset, 128);
RequireEnd(payload, offset);
if (expectedVersion < 1 || expectedVersion == long.MaxValue)
throw new InvalidDataException("Invalid rotation version.");
PpapCryptography.ValidatePublicKey(key);
var identity = PpapCryptography.NormalizeIdentity(
PpapCryptography.DecodeUtf8(identityBytes,
PpapProtocol.MaximumIdentityBytes, "identity"));
var descriptor = DecodeDescriptor(descriptorBytes);
if (descriptor.Kind != PpapIdentityKind.PasswordDerived
|| descriptor.Version != expectedVersion + 1)
throw new InvalidDataException("Invalid rotated registration descriptor.");
PpapCryptography.Clear(identityBytes);
PpapCryptography.Clear(descriptorBytes);
return new RotationOffer
{
Role = role,
Identity = identity,
ExpectedVersion = expectedVersion,
Nonce = descriptor.Nonce,
EncapsulationKey = key,
KdfProfile = descriptor.KdfProfile,
};
}
internal static byte[] EncodeRotationChallenge(PpapIdentityRole role,
byte[] ciphertext)
{
ValidateRole(role);
ValidateExact(ciphertext, PpapProtocol.CiphertextLength, nameof(ciphertext));
return Encode(PpapMessageType.RotationChallenge, payload =>
{
payload.WriteByte((byte)role);
WriteField(payload, ciphertext);
});
}
internal static RotationChallenge DecodeRotationChallenge(object data)
{
var payload = DecodeEnvelope(RequireWireBytes(data),
PpapMessageType.RotationChallenge);
var offset = 0;
var role = ReadRole(payload, ref offset);
var ciphertext = ReadField(payload, ref offset,
PpapProtocol.CiphertextLength);
RequireEnd(payload, offset);
ValidateExact(ciphertext, PpapProtocol.CiphertextLength,
nameof(ciphertext));
return new RotationChallenge { Role = role, Ciphertext = ciphertext };
}
internal static byte[] EncodeRotationProof(PpapIdentityRole role, byte[] proof)
{
ValidateRole(role);
ValidateExact(proof, PpapProtocol.HashLength, nameof(proof));
return Encode(PpapMessageType.RotationProof, payload =>
{
payload.WriteByte((byte)role);
WriteField(payload, proof);
});
}
internal static RotationProof DecodeRotationProof(object data)
{
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.RotationProof);
var offset = 0;
var role = ReadRole(payload, ref offset);
var proof = ReadField(payload, ref offset, PpapProtocol.HashLength);
RequireEnd(payload, offset);
ValidateExact(proof, PpapProtocol.HashLength, nameof(proof));
return new RotationProof { Role = role, Proof = proof };
}
internal static byte[] EncodeRotationCommit(PpapIdentityRole role, long version)
=> EncodeRoleVersion(PpapMessageType.RotationCommit, role, version);
internal static long DecodeRotationCommit(object data,
PpapIdentityRole expectedRole)
=> DecodeRoleVersion(data, PpapMessageType.RotationCommit, expectedRole);
internal static byte[] EncodeRotationCommitAck(PpapIdentityRole role, long version)
=> EncodeRoleVersion(PpapMessageType.RotationCommitAck, role, version);
internal static long DecodeRotationCommitAck(object data,
PpapIdentityRole expectedRole)
=> DecodeRoleVersion(data, PpapMessageType.RotationCommitAck, expectedRole);
internal static byte[] EncodeRotationDone()
=> Encode(PpapMessageType.RotationDone, _ => { });
internal static void DecodeRotationDone(object data)
{
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.RotationDone);
RequireEnd(payload, 0);
}
internal static PpapMessageType PeekMessageType(object data)
{
var input = RequireWireBytes(data);
if (input.Length < 10)
throw new InvalidDataException("Invalid PPAP message length.");
return (PpapMessageType)input[5];
}
static byte[] EncodeRoleVersion(PpapMessageType type, PpapIdentityRole role,
long version)
{
ValidateRole(role);
if (version < 2)
throw new InvalidDataException("Invalid committed registration version.");
return Encode(type, payload =>
{
payload.WriteByte((byte)role);
WriteInt64(payload, version);
});
}
static long DecodeRoleVersion(object data, PpapMessageType type,
PpapIdentityRole expectedRole)
{
var payload = DecodeEnvelope(RequireWireBytes(data), type);
var offset = 0;
var role = ReadRole(payload, ref offset);
var version = ReadInt64(payload, ref offset);
RequireEnd(payload, offset);
if (role != expectedRole || version < 2)
throw new InvalidDataException("Unexpected rotation commit.");
return version;
}
static PpapIdentityRole DecodeRoleOnly(object data, PpapMessageType type)
{
var payload = DecodeEnvelope(RequireWireBytes(data), type);
var offset = 0;
var role = ReadRole(payload, ref offset);
RequireEnd(payload, offset);
return role;
}
static PpapIdentityRole ReadRole(byte[] payload, ref int offset)
{
if (offset >= payload.Length)
throw new InvalidDataException("Missing PPAP identity role.");
var role = (PpapIdentityRole)payload[offset++];
ValidateRole(role);
return role;
}
static byte ValidateRole(PpapIdentityRole role)
{
if (role != PpapIdentityRole.Initiator
&& role != PpapIdentityRole.Responder)
throw new InvalidDataException("Invalid PPAP identity role.");
return (byte)role;
}
}
@@ -0,0 +1,626 @@
using System;
using System.IO;
using System.Text;
namespace Esiur.Security.Authority.Providers.Ppap;
/// <summary>
/// Stable message codes for the ppap-mlkem768-v1 authentication and rotation protocols.
/// Authentication payloads are canonical byte arrays, not runtime-specific object graphs.
/// </summary>
public enum PpapMessageType : byte
{
ClientHello = 1,
ServerHello = 2,
InitiatorProof = 3,
ResponderProof = 4,
InitiatorFinished = 5,
RotationStart = 16,
RotationOffer = 17,
RotationChallenge = 18,
RotationProof = 19,
RotationCommit = 20,
RotationCommitAck = 21,
RotationDone = 22,
}
public enum PpapIdentityRole : byte
{
Initiator = 1,
Responder = 2,
}
internal sealed class PpapRegistrationDescriptor
{
public PpapIdentityKind Kind { get; }
public long Version { get; }
public byte[] Nonce { get; }
public PpapKdfProfile KdfProfile { get; }
public PpapRegistrationDescriptor(PpapIdentityKind kind, long version,
byte[] nonce, PpapKdfProfile kdfProfile)
{
if (version < 1)
throw new InvalidDataException("Invalid registration version.");
Kind = kind;
Version = version;
KdfProfile = kdfProfile;
if (kind == PpapIdentityKind.PasswordDerived)
{
if (nonce == null || nonce.Length != PpapProtocol.RegistrationNonceLength
|| kdfProfile == null)
throw new InvalidDataException("Invalid password registration descriptor.");
Nonce = (byte[])nonce.Clone();
}
else if (kind == PpapIdentityKind.StaticMlKem)
{
if ((nonce != null && nonce.Length != 0) || kdfProfile != null)
throw new InvalidDataException("Invalid static-key registration descriptor.");
Nonce = Array.Empty<byte>();
}
else
{
throw new InvalidDataException("Unknown PPAP identity kind.");
}
}
public static PpapRegistrationDescriptor FromRecord(PpapRegistrationRecord record)
{
if (record == null)
throw new ArgumentNullException(nameof(record));
return new PpapRegistrationDescriptor(record.Kind, record.Version,
record.NonceBytes, record.KdfProfile);
}
}
internal static partial class PpapWire
{
static readonly byte[] Magic = { (byte)'P', (byte)'P', (byte)'A', (byte)'P' };
internal sealed class ClientHello
{
public AuthenticationMode Mode;
public string Domain;
public byte[] EphemeralKey;
public byte[] InitiatorMask;
}
internal sealed class ServerHello
{
public byte[] EphemeralCiphertext;
public byte[] ResponderMask;
public byte[] MaskedResponderIdentity;
}
internal sealed class InitiatorProof
{
public byte[] MaskedInitiatorIdentity;
public byte[] ResponderCiphertext;
public PpapRegistrationDescriptor ResponderRegistration;
}
internal sealed class ResponderProof
{
public byte[] InitiatorCiphertext;
public PpapRegistrationDescriptor InitiatorRegistration;
public byte[] Finished;
public byte[] TranscriptCore;
}
internal static byte[] EncodeClientHello(AuthenticationMode mode, string domain,
byte[] ephemeralKey, byte[] initiatorMask)
{
ValidateMode(mode);
PpapCryptography.ValidatePublicKey(ephemeralKey);
ValidateExact(initiatorMask, PpapProtocol.IdentityMaskLength, nameof(initiatorMask));
var domainBytes = PpapCryptography.EncodeUtf8(
PpapCryptography.NormalizeDomain(domain), PpapProtocol.MaximumDomainBytes,
nameof(domain));
try
{
return Encode(PpapMessageType.ClientHello, payload =>
{
payload.WriteByte((byte)mode);
WriteField(payload, domainBytes);
WriteField(payload, ephemeralKey);
WriteField(payload, initiatorMask);
});
}
finally
{
PpapCryptography.Clear(domainBytes);
}
}
internal static ClientHello DecodeClientHello(object data)
{
var input = RequireWireBytes(data);
var payload = DecodeEnvelope(input, PpapMessageType.ClientHello);
var offset = 0;
if (payload.Length == 0)
throw new InvalidDataException("Missing authentication mode.");
var mode = (AuthenticationMode)payload[offset++];
ValidateMode(mode);
var domainBytes = ReadField(payload, ref offset, PpapProtocol.MaximumDomainBytes);
var key = ReadField(payload, ref offset, PpapProtocol.PublicKeyLength);
var mask = ReadField(payload, ref offset, PpapProtocol.IdentityMaskLength);
RequireEnd(payload, offset);
PpapCryptography.ValidatePublicKey(key);
ValidateExact(mask, PpapProtocol.IdentityMaskLength, nameof(mask));
var domain = PpapCryptography.NormalizeDomain(PpapCryptography.DecodeUtf8(
domainBytes, PpapProtocol.MaximumDomainBytes, "domain"));
PpapCryptography.Clear(domainBytes);
return new ClientHello
{
Mode = mode,
Domain = domain,
EphemeralKey = key,
InitiatorMask = mask,
};
}
internal static byte[] EncodeServerHello(byte[] ephemeralCiphertext,
byte[] responderMask, byte[] maskedResponderIdentity)
{
ValidateExact(ephemeralCiphertext, PpapProtocol.CiphertextLength,
nameof(ephemeralCiphertext));
ValidateExact(responderMask, PpapProtocol.IdentityMaskLength,
nameof(responderMask));
ValidateOptional(maskedResponderIdentity, PpapProtocol.HashLength,
nameof(maskedResponderIdentity));
return Encode(PpapMessageType.ServerHello, payload =>
{
WriteField(payload, ephemeralCiphertext);
WriteField(payload, responderMask);
WriteField(payload, maskedResponderIdentity);
});
}
internal static ServerHello DecodeServerHello(object data, bool responderAuthenticated)
{
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.ServerHello);
var offset = 0;
var ciphertext = ReadField(payload, ref offset, PpapProtocol.CiphertextLength);
var mask = ReadField(payload, ref offset, PpapProtocol.IdentityMaskLength);
var maskedIdentity = ReadField(payload, ref offset, PpapProtocol.HashLength);
RequireEnd(payload, offset);
ValidateExact(ciphertext, PpapProtocol.CiphertextLength, nameof(ciphertext));
ValidateExact(mask, PpapProtocol.IdentityMaskLength, nameof(mask));
ValidatePresence(maskedIdentity, responderAuthenticated, PpapProtocol.HashLength,
nameof(maskedIdentity));
return new ServerHello
{
EphemeralCiphertext = ciphertext,
ResponderMask = mask,
MaskedResponderIdentity = maskedIdentity,
};
}
internal static byte[] EncodeInitiatorProof(byte[] maskedInitiatorIdentity,
byte[] responderCiphertext, PpapRegistrationDescriptor responderRegistration,
string domain, byte[] ephemeralSecret)
{
ValidateOptional(maskedInitiatorIdentity, PpapProtocol.HashLength,
nameof(maskedInitiatorIdentity));
ValidateOptional(responderCiphertext, PpapProtocol.CiphertextLength,
nameof(responderCiphertext));
var descriptor = ProtectDescriptor(responderRegistration,
PpapIdentityRole.Responder, domain, ephemeralSecret);
try
{
return Encode(PpapMessageType.InitiatorProof, payload =>
{
WriteField(payload, maskedInitiatorIdentity);
WriteField(payload, responderCiphertext);
WriteField(payload, descriptor);
});
}
finally
{
PpapCryptography.Clear(descriptor);
}
}
internal static InitiatorProof DecodeInitiatorProof(object data,
bool initiatorAuthenticated, bool responderAuthenticated,
string domain, byte[] ephemeralSecret)
{
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.InitiatorProof);
var offset = 0;
var maskedIdentity = ReadField(payload, ref offset, PpapProtocol.HashLength);
var ciphertext = ReadField(payload, ref offset, PpapProtocol.CiphertextLength);
var descriptorBytes = ReadField(payload, ref offset,
PpapProtocol.ProtectedDescriptorLength);
RequireEnd(payload, offset);
ValidatePresence(maskedIdentity, initiatorAuthenticated, PpapProtocol.HashLength,
nameof(maskedIdentity));
ValidatePresence(ciphertext, responderAuthenticated, PpapProtocol.CiphertextLength,
nameof(ciphertext));
ValidatePresence(descriptorBytes, responderAuthenticated,
PpapProtocol.ProtectedDescriptorLength, nameof(descriptorBytes));
try
{
var descriptor = responderAuthenticated
? UnprotectDescriptor(descriptorBytes, PpapIdentityRole.Responder,
domain, ephemeralSecret)
: null;
return new InitiatorProof
{
MaskedInitiatorIdentity = maskedIdentity,
ResponderCiphertext = ciphertext,
ResponderRegistration = descriptor,
};
}
finally
{
PpapCryptography.Clear(descriptorBytes);
}
}
internal static byte[] EncodeResponderProofCore(byte[] initiatorCiphertext,
PpapRegistrationDescriptor initiatorRegistration, string domain,
byte[] ephemeralSecret, out byte[] protectedDescriptor)
{
ValidateOptional(initiatorCiphertext, PpapProtocol.CiphertextLength,
nameof(initiatorCiphertext));
protectedDescriptor = ProtectDescriptor(initiatorRegistration,
PpapIdentityRole.Initiator, domain, ephemeralSecret);
try
{
return EncodeResponderProofCoreProtected(initiatorCiphertext,
protectedDescriptor);
}
catch
{
PpapCryptography.Clear(protectedDescriptor);
protectedDescriptor = null;
throw;
}
}
internal static byte[] EncodeResponderProof(byte[] initiatorCiphertext,
byte[] protectedInitiatorDescriptor, byte[] finished)
{
ValidateOptional(initiatorCiphertext, PpapProtocol.CiphertextLength,
nameof(initiatorCiphertext));
ValidateOptional(protectedInitiatorDescriptor,
PpapProtocol.ProtectedDescriptorLength,
nameof(protectedInitiatorDescriptor));
ValidateExact(finished, PpapProtocol.FinishedTagLength, nameof(finished));
return Encode(PpapMessageType.ResponderProof, payload =>
{
WriteField(payload, initiatorCiphertext);
WriteField(payload, protectedInitiatorDescriptor);
WriteField(payload, finished);
});
}
internal static ResponderProof DecodeResponderProof(object data,
bool initiatorAuthenticated, string domain, byte[] ephemeralSecret)
{
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.ResponderProof);
var offset = 0;
var ciphertext = ReadField(payload, ref offset, PpapProtocol.CiphertextLength);
var descriptorBytes = ReadField(payload, ref offset,
PpapProtocol.ProtectedDescriptorLength);
var finished = ReadField(payload, ref offset, PpapProtocol.FinishedTagLength);
RequireEnd(payload, offset);
ValidatePresence(ciphertext, initiatorAuthenticated, PpapProtocol.CiphertextLength,
nameof(ciphertext));
ValidatePresence(descriptorBytes, initiatorAuthenticated,
PpapProtocol.ProtectedDescriptorLength, nameof(descriptorBytes));
ValidateExact(finished, PpapProtocol.FinishedTagLength, nameof(finished));
try
{
var descriptor = initiatorAuthenticated
? UnprotectDescriptor(descriptorBytes, PpapIdentityRole.Initiator,
domain, ephemeralSecret)
: null;
var core = EncodeResponderProofCoreProtected(ciphertext,
descriptorBytes);
return new ResponderProof
{
InitiatorCiphertext = ciphertext,
InitiatorRegistration = descriptor,
Finished = finished,
TranscriptCore = core,
};
}
finally
{
PpapCryptography.Clear(descriptorBytes);
}
}
static byte[] EncodeResponderProofCoreProtected(byte[] initiatorCiphertext,
byte[] protectedInitiatorDescriptor)
{
ValidateOptional(initiatorCiphertext, PpapProtocol.CiphertextLength,
nameof(initiatorCiphertext));
ValidateOptional(protectedInitiatorDescriptor,
PpapProtocol.ProtectedDescriptorLength,
nameof(protectedInitiatorDescriptor));
return Encode(PpapMessageType.ResponderProof, payload =>
{
WriteField(payload, initiatorCiphertext);
WriteField(payload, protectedInitiatorDescriptor);
});
}
internal static byte[] EncodeInitiatorFinished(byte[] finished)
{
ValidateExact(finished, PpapProtocol.FinishedTagLength, nameof(finished));
return Encode(PpapMessageType.InitiatorFinished,
payload => WriteField(payload, finished));
}
internal static byte[] DecodeInitiatorFinished(object data)
{
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.InitiatorFinished);
var offset = 0;
var finished = ReadField(payload, ref offset, PpapProtocol.FinishedTagLength);
RequireEnd(payload, offset);
ValidateExact(finished, PpapProtocol.FinishedTagLength, nameof(finished));
return finished;
}
internal static byte[] EncodeAuthenticationContext(AuthenticationMode mode,
string domain, string initiatorIdentity, PpapIdentityKind? initiatorKind,
string responderIdentity, PpapIdentityKind? responderKind)
{
ValidateMode(mode);
var needsInitiator = mode == AuthenticationMode.InitializerIdentity
|| mode == AuthenticationMode.DualIdentity;
var needsResponder = mode == AuthenticationMode.ResponderIdentity
|| mode == AuthenticationMode.DualIdentity;
if (needsInitiator != initiatorKind.HasValue
|| needsInitiator != (initiatorIdentity != null)
|| needsResponder != responderKind.HasValue
|| needsResponder != (responderIdentity != null))
throw new InvalidDataException("The PPAP authentication context is incomplete.");
if (initiatorKind.HasValue
&& !Enum.IsDefined(typeof(PpapIdentityKind), initiatorKind.Value))
throw new InvalidDataException("Invalid initiator identity kind.");
if (responderKind.HasValue
&& !Enum.IsDefined(typeof(PpapIdentityKind), responderKind.Value))
throw new InvalidDataException("Invalid responder identity kind.");
var protocol = Encoding.ASCII.GetBytes(PpapProtocol.Name);
var modeBytes = new[] { (byte)mode };
var domainBytes = PpapCryptography.EncodeUtf8(
PpapCryptography.NormalizeDomain(domain), PpapProtocol.MaximumDomainBytes,
nameof(domain));
var initiatorBytes = PpapCryptography.EncodeUtf8(
initiatorIdentity == null ? string.Empty
: PpapCryptography.NormalizeIdentity(initiatorIdentity),
PpapProtocol.MaximumIdentityBytes, nameof(initiatorIdentity));
var responderBytes = PpapCryptography.EncodeUtf8(
responderIdentity == null ? string.Empty
: PpapCryptography.NormalizeIdentity(responderIdentity),
PpapProtocol.MaximumIdentityBytes, nameof(responderIdentity));
var kinds = new[]
{
initiatorKind.HasValue ? (byte)initiatorKind.Value : (byte)0,
responderKind.HasValue ? (byte)responderKind.Value : (byte)0,
};
try
{
return PpapCryptography.EncodeFrames(protocol, modeBytes, domainBytes,
initiatorBytes, responderBytes, kinds);
}
finally
{
PpapCryptography.Clear(protocol);
PpapCryptography.Clear(domainBytes);
PpapCryptography.Clear(initiatorBytes);
PpapCryptography.Clear(responderBytes);
PpapCryptography.Clear(kinds);
}
}
static byte[] ProtectDescriptor(PpapRegistrationDescriptor descriptor,
PpapIdentityRole role, string domain, byte[] ephemeralSecret)
{
var plaintext = EncodeDescriptor(descriptor);
if (plaintext.Length == 0)
return plaintext;
try
{
return PpapCryptography.ProtectRegistrationDescriptor(domain, role,
ephemeralSecret, plaintext);
}
finally
{
PpapCryptography.Clear(plaintext);
}
}
static PpapRegistrationDescriptor UnprotectDescriptor(byte[] descriptor,
PpapIdentityRole role, string domain, byte[] ephemeralSecret)
{
var plaintext = PpapCryptography.UnprotectRegistrationDescriptor(domain,
role, ephemeralSecret, descriptor);
try
{
return DecodeDescriptor(plaintext);
}
finally
{
PpapCryptography.Clear(plaintext);
}
}
static byte[] EncodeDescriptor(PpapRegistrationDescriptor descriptor)
{
if (descriptor == null)
return Array.Empty<byte>();
using (var output = new MemoryStream())
{
output.WriteByte((byte)descriptor.Kind);
WriteInt64(output, descriptor.Version);
WriteField(output, descriptor.Nonce);
if (descriptor.Kind == PpapIdentityKind.PasswordDerived)
{
output.WriteByte(1);
PpapCryptography.WriteInt32(output, descriptor.KdfProfile.Version);
PpapCryptography.WriteInt32(output, descriptor.KdfProfile.MemoryKiB);
PpapCryptography.WriteInt32(output, descriptor.KdfProfile.Iterations);
PpapCryptography.WriteInt32(output, descriptor.KdfProfile.Parallelism);
}
else
{
output.WriteByte(0);
}
return output.ToArray();
}
}
static PpapRegistrationDescriptor DecodeDescriptor(byte[] input)
{
if (input == null || input.Length < 10 || input.Length > 128)
throw new InvalidDataException("Invalid registration descriptor.");
var offset = 0;
var kind = (PpapIdentityKind)input[offset++];
var version = ReadInt64(input, ref offset);
var nonce = ReadField(input, ref offset, PpapProtocol.RegistrationNonceLength);
if (offset >= input.Length)
throw new InvalidDataException("Truncated registration descriptor.");
var hasProfile = input[offset++];
PpapKdfProfile profile = null;
if (hasProfile == 1)
{
var profileVersion = PpapCryptography.ReadInt32(input, ref offset);
var memory = PpapCryptography.ReadInt32(input, ref offset);
var iterations = PpapCryptography.ReadInt32(input, ref offset);
var parallelism = PpapCryptography.ReadInt32(input, ref offset);
profile = new PpapKdfProfile(profileVersion, memory, iterations, parallelism);
}
else if (hasProfile != 0)
{
throw new InvalidDataException("Invalid KDF profile marker.");
}
RequireEnd(input, offset);
return new PpapRegistrationDescriptor(kind, version, nonce, profile);
}
static byte[] Encode(PpapMessageType type, Action<MemoryStream> writePayload)
{
using (var payload = new MemoryStream())
{
writePayload(payload);
if (payload.Length > PpapProtocol.MaximumWireMessageBytes - 10)
throw new InvalidDataException("PPAP payload is too large.");
using (var message = new MemoryStream())
{
message.Write(Magic, 0, Magic.Length);
message.WriteByte(PpapProtocol.WireVersion);
message.WriteByte((byte)type);
PpapCryptography.WriteInt32(message, (int)payload.Length);
payload.Position = 0;
payload.CopyTo(message);
return message.ToArray();
}
}
}
static byte[] DecodeEnvelope(byte[] input, PpapMessageType expectedType)
{
if (input.Length < 10 || input.Length > PpapProtocol.MaximumWireMessageBytes)
throw new InvalidDataException("Invalid PPAP message length.");
for (var i = 0; i < Magic.Length; i++)
if (input[i] != Magic[i])
throw new InvalidDataException("Invalid PPAP message magic.");
if (input[4] != PpapProtocol.WireVersion)
throw new InvalidDataException("Unsupported PPAP wire version.");
if (input[5] != (byte)expectedType)
throw new InvalidDataException("Unexpected PPAP message type.");
var offset = 6;
var length = PpapCryptography.ReadInt32(input, ref offset);
if (length != input.Length - offset)
throw new InvalidDataException("Invalid PPAP payload length.");
var payload = new byte[length];
Buffer.BlockCopy(input, offset, payload, 0, length);
return payload;
}
static byte[] RequireWireBytes(object data)
{
if (!(data is byte[] input))
throw new InvalidDataException("PPAP authentication data must be a byte array.");
return input;
}
static void WriteField(Stream output, byte[] value)
{
var field = value ?? Array.Empty<byte>();
PpapCryptography.WriteInt32(output, field.Length);
output.Write(field, 0, field.Length);
}
static byte[] ReadField(byte[] input, ref int offset, int maximumLength)
{
var length = PpapCryptography.ReadInt32(input, ref offset);
if (length > maximumLength || input.Length - offset < length)
throw new InvalidDataException("Invalid PPAP field length.");
var field = new byte[length];
Buffer.BlockCopy(input, offset, field, 0, length);
offset += length;
return field;
}
static void WriteInt64(Stream output, long value)
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(value));
for (var shift = 56; shift >= 0; shift -= 8)
output.WriteByte((byte)(value >> shift));
}
static long ReadInt64(byte[] input, ref int offset)
{
if (input.Length - offset < 8)
throw new InvalidDataException("Truncated PPAP integer.");
long value = 0;
for (var i = 0; i < 8; i++)
value = (value << 8) | input[offset++];
if (value < 0)
throw new InvalidDataException("Negative PPAP integer.");
return value;
}
static void ValidateMode(AuthenticationMode mode)
{
if (mode != AuthenticationMode.InitializerIdentity
&& mode != AuthenticationMode.ResponderIdentity
&& mode != AuthenticationMode.DualIdentity)
throw new InvalidDataException("Unsupported PPAP authentication mode.");
}
static void ValidateExact(byte[] value, int length, string name)
{
if (value == null || value.Length != length)
throw new InvalidDataException($"{name} has an invalid length.");
}
static void ValidateOptional(byte[] value, int length, string name)
{
if (value != null && value.Length != 0 && value.Length != length)
throw new InvalidDataException($"{name} has an invalid length.");
}
static void ValidatePresence(byte[] value, bool required, int length, string name)
{
if (required)
ValidateExact(value, length, name);
else if (value == null || value.Length != 0)
throw new InvalidDataException($"{name} must be absent.");
}
static void RequireEnd(byte[] input, int offset)
{
if (offset != input.Length)
throw new InvalidDataException("Trailing PPAP message data.");
}
}
+691
View File
@@ -0,0 +1,691 @@
# PPAP ML-KEM-768 v1
## Status and scope
This document specifies the byte-for-byte interoperable form of Esiur's fixed
PPAP suite named `ppap-mlkem768-v1`, including its required encrypted
post-authentication registration rotation. It describes the protocol currently
implemented by `PpapWire`, `PpapCryptography`, `PpapAuthenticationHandler`, and
`EpConnection`.
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, and **SHOULD NOT**
are normative requirements. There is no algorithm negotiation inside PPAP v1.
## 1. Notation and canonical encodings
- `A || B` is octet concatenation.
- `U8(n)` is one unsigned octet.
- `U32(n)` is an unsigned 32-bit integer in big-endian order. The current wire
reader accepts only `0..2^31-1`; values with the high bit set are invalid.
- `U64(n)` is an unsigned 64-bit integer in big-endian order. The implementation
uses a non-negative signed 64-bit value, so the accepted range is
`0..2^63-1`.
- `F(x)` is `U32(len(x)) || x`.
- `FRAMES(x1, ..., xn)` is `F(x1) || ... || F(xn)`. A null or absent input is
encoded as a zero-length field, `00 00 00 00`.
- `H(x)` is SHA3-256 over `x`.
- `MAC(k, x)` is HMAC-SHA3-256 with key `k` over `x`.
- `HKDF(ikm, salt, info, L)` is RFC 5869 HKDF using SHA3-256 and producing `L`
octets.
- `UTF8(s)` is UTF-8 without a BOM. Encoders and decoders MUST reject invalid
Unicode/UTF-8. Identities and domains MUST be normalized to Unicode NFC before
UTF-8 encoding and before ordinal comparison.
An identity MUST be non-null, non-empty, and not entirely whitespace. A domain
may be empty; a null domain is the empty domain. An encoded identity or domain
MUST NOT exceed 512 octets. Implementations MUST use the canonical encodings in
this section even when their host language has a different default byte order,
text encoding, or string comparison.
## 2. Fixed suite
| Parameter | Required value |
|---|---:|
| Protocol name | `ppap-mlkem768-v1` |
| PPAP wire version | `1` |
| KEM | ML-KEM-768 |
| ML-KEM public key | 1184 octets |
| ML-KEM private-key encoding | 2400 octets |
| ML-KEM ciphertext | 1088 octets |
| ML-KEM shared secret | 32 octets |
| Deterministic ML-KEM seed | 64 octets |
| Hash/MAC/Finished | 32 octets |
| Identity mask and registration nonce | 32 octets each |
| Session key | 32 octets |
| Descriptor AEAD | AES-256-GCM, 128-bit tag |
| Descriptor plaintext/key/nonce/tag | 128/32/12/16 octets |
| Protected descriptor | 144 octets |
| Maximum password | 4096 octets, non-empty |
| Maximum complete PPAP envelope | 8192 octets |
ML-KEM key parsing MUST validate the encoded key as well as its exact length.
ML-KEM encapsulation randomness and every randomly generated ephemeral/static
key seed, identity mask, and registration nonce MUST come from a
cryptographically secure random source.
### 2.1 Domain-separation labels
Every label below is the exact sequence of displayed ASCII octets, without a
terminating NUL:
| Purpose | ASCII label |
|---|---|
| Password seed | `esiur/ppap-mlkem768-v1/password-seed` |
| Masked identity | `esiur/ppap-mlkem768-v1/masked-identity` |
| Descriptor salt | `esiur/ppap-mlkem768-v1/descriptor-salt` |
| Descriptor key | `esiur/ppap-mlkem768-v1/descriptor-key` |
| Descriptor nonce | `esiur/ppap-mlkem768-v1/descriptor-nonce` |
| Descriptor AAD | `esiur/ppap-mlkem768-v1/descriptor-aad` |
| Initiator descriptor role | `esiur/ppap-mlkem768-v1/initiator-registration` |
| Responder descriptor role | `esiur/ppap-mlkem768-v1/responder-registration` |
| Transcript | `esiur/ppap-mlkem768-v1/transcript` |
| Key-schedule input | `esiur/ppap-mlkem768-v1/key-schedule` |
| Session-key info | `esiur/ppap-mlkem768-v1/session-key` |
| Initiator Finished-key info | `esiur/ppap-mlkem768-v1/initiator-finished-key` |
| Responder Finished-key info | `esiur/ppap-mlkem768-v1/responder-finished-key` |
| Initiator Finished | `esiur/ppap-mlkem768-v1/initiator-finished` |
| Responder Finished | `esiur/ppap-mlkem768-v1/responder-finished` |
| Rotation proof | `esiur/ppap-mlkem768-v1/rotation-proof` |
## 3. Codes and PPAP envelope
All numeric codes in this section are hexadecimal. Authentication modes use
these exact octets:
| Code | Name | Authenticated role(s) |
|---:|---|---|
| `01` | `InitializerIdentity` | Initiator |
| `02` | `ResponderIdentity` | Responder |
| `03` | `DualIdentity` | Initiator and responder |
Mode `00` (`None`) is not supported by PPAP. The code spelling
`InitializerIdentity` is retained for compatibility; it means the connection
initiator.
Identity roles are initiator `01` and responder `02`. Identity kinds are
password-derived `01` and static ML-KEM `02`.
Every PPAP message is encoded as:
```text
50 50 41 50 || 01 || U8(messageType) || U32(payloadLength) || payload
```
`50 50 41 50` is ASCII `PPAP`. The total envelope MUST be between 10 and 8192
octets, so the payload is at most 8182 octets. The declared payload length MUST
equal the exact remaining length, and trailing data is forbidden.
| Type | Code | Type | Code |
|---|---:|---|---:|
| ClientHello | `01` | RotationStart | `10` |
| ServerHello | `02` | RotationOffer | `11` |
| InitiatorProof | `03` | RotationChallenge | `12` |
| ResponderProof | `04` | RotationProof | `13` |
| InitiatorFinished | `05` | RotationCommit | `14` |
| | | RotationCommitAck | `15` |
| | | RotationDone | `16` |
All variable fields below use `F(value)`, including absent fields. A decoder
MUST enforce each field's exact required size and MUST reject trailing payload
octets.
## 4. Registration material
### 4.1 Password-derived ML-KEM key
The default KDF profile is Argon2id version `0x13` (1.3), memory 32768 KiB,
three iterations, and parallelism one. An accepted profile MUST satisfy all of:
- version is exactly `0x13`;
- memory is 8192 through 262144 KiB, inclusive;
- iterations are 1 through 10, inclusive;
- parallelism is 1 through 16, inclusive; and
- memory in KiB is at least `parallelism * 8`.
Given a 32-octet registration nonce, password octets are converted to a
deterministic ML-KEM-768 private key as follows:
```text
argonInput = FRAMES(
ASCII("esiur/ppap-mlkem768-v1/password-seed"),
UTF8(domain),
UTF8(identity),
passwordBytes,
registrationNonce)
seed = Argon2id(
password = argonInput,
salt = registrationNonce,
version/memory/iterations/parallelism = registration profile,
outputLength = 64)
d = seed[0..32)
z = seed[32..64)
(publicKey, privateKey) = ML-KEM-768.KeyGen_internal(d, z)
```
`KeyGen_internal` is the deterministic internal key-generation operation from
FIPS 203; the public and private values use the standard ML-KEM-768 encapsulation
and decapsulation-key encodings. This is the cross-language meaning of the .NET
implementation's `MLKemPrivateKeyParameters.FromSeed(seed)`. The text-password
API normalizes the password to NFC and then UTF-8 encodes it. The byte-password
API uses the supplied octets unchanged. Provisioning and authentication
implementations MUST make the same choice.
A static identity instead uses a provisioned ML-KEM-768 key pair and has neither
a registration nonce nor a KDF profile. The `CreateStatic` API generates a new
pair from a cryptographically random seed; the `FromStaticKey` API may import an
existing validated 2400-octet private-key encoding.
### 4.2 Canonical registration descriptor
```text
U8(kind)
|| U64(version)
|| F(nonce)
|| U8(hasProfile)
|| profile-if-present
profile-if-present =
U32(argonVersion)
|| U32(memoryKiB)
|| U32(iterations)
|| U32(parallelism)
```
The version MUST be at least one. A password-derived descriptor has a 32-octet
nonce, `hasProfile = 01`, and the 16-octet profile, for a canonical total of 62
octets. A static descriptor has an empty nonce and `hasProfile = 00`, for a
canonical total of 14 octets. Other marker values, kinds, combinations, and
trailing data MUST be rejected. The generic descriptor decoder bounds input to
10 through 128 octets.
### 4.3 Protected handshake descriptor
Descriptors in `InitiatorProof` and `ResponderProof` are protected independently
of the later EP transport encryption. The plaintext is:
```text
F(canonicalDescriptor) || zeroPadding
```
It is exactly 128 octets. The descriptor length MUST be 1 through 124, and every
padding octet MUST be zero. Define the role-specific values:
| Described registration | `role` | `roleLabel` | Containing type | `binding` |
|---|---:|---|---:|---|
| Initiator | `01` | initiator descriptor role label | `04` ResponderProof | `01 01 04` |
| Responder | `02` | responder descriptor role label | `03` InitiatorProof | `01 02 03` |
Here the first binding octet is the PPAP wire version. Let `D = UTF8(domain)`:
```text
salt = H(FRAMES(descriptor-salt-label, roleLabel, D, binding))
key = HKDF(ephemeralSecret, salt,
FRAMES(descriptor-key-label, roleLabel, D, binding), 32)
nonce = HKDF(ephemeralSecret, salt,
FRAMES(descriptor-nonce-label, roleLabel, D, binding), 12)
aad = FRAMES(descriptor-aad-label, roleLabel, D, binding)
```
The protected descriptor is AES-256-GCM encryption of the 128-octet plaintext,
with `key`, `nonce`, and `aad`, serialized as 128 octets of ciphertext followed
by the 16-octet tag. The derived nonce is not sent. An implementation MUST
encrypt at most one descriptor for each role under one ephemeral secret. In
particular, the responder MUST reuse the same protected initiator descriptor
when constructing `ResponderProofCore` and the transmitted `ResponderProof`.
## 5. Authentication messages
Let `ephemeralPublicKey` be the initiator's fresh ML-KEM-768 public key; `mI`
and `mR` are fresh 32-octet masks selected by initiator and responder.
```text
ClientHello (01) payload =
U8(mode)
|| F(UTF8(domain))
|| F(ephemeralPublicKey[1184])
|| F(mI[32])
ServerHello (02) payload =
F(ephemeralCiphertext[1088])
|| F(mR[32])
|| F(maskedResponderIdentity[32] or empty)
InitiatorProof (03) payload =
F(maskedInitiatorIdentity[32] or empty)
|| F(responderIdentityCiphertext[1088] or empty)
|| F(protectedResponderDescriptor[144] or empty)
ResponderProof (04) payload =
F(initiatorIdentityCiphertext[1088] or empty)
|| F(protectedInitiatorDescriptor[144] or empty)
|| F(responderFinished[32])
InitiatorFinished (05) payload =
F(initiatorFinished[32])
```
Field presence is exact:
| Field | `InitializerIdentity` | `ResponderIdentity` | `DualIdentity` |
|---|:---:|:---:|:---:|
| `maskedResponderIdentity` | empty | 32 | 32 |
| `maskedInitiatorIdentity` | 32 | empty | 32 |
| responder identity ciphertext + descriptor | empty | present | present |
| initiator identity ciphertext + descriptor | present | empty | present |
An absent field is encoded as `F(empty)`, not omitted.
### 5.1 KEM and masked identities
The responder encapsulates to `ephemeralPublicKey`; both endpoints call the
resulting 32-octet value `ephemeralSecret`. For each authenticated identity, the
endpoint holding that identity's verifier registration encapsulates to its
registered public key. The identity subject decapsulates using its static or
password-derived private key. The resulting values are
`initiatorIdentitySecret` and `responderIdentitySecret`; the secret for an
unauthenticated role is empty in the key schedule.
An identity is masked as:
```text
maskedIdentity = MAC(
ephemeralSecret,
FRAMES(masked-identity-label, UTF8(domain), UTF8(identity), mask))
```
The responder identity uses `mI`; the initiator identity uses `mR`. A verifier
store resolves the result only within the normalized domain. It MUST compare
candidates in constant time, reject no match or more than one match, and MUST
NOT persist the per-handshake `ephemeralSecret` or masked value as a stable
lookup token.
This masking provides identity confidentiality against a passive observer of
the clear authentication exchange. It does not provide anonymity from an active
peer: a peer participating in the ephemeral KEM knows `ephemeralSecret` and can
test identities it is otherwise able to enumerate.
### 5.2 Authentication context, transcript, and keys
The authentication context is:
```text
FRAMES(
ASCII("ppap-mlkem768-v1"),
U8(mode),
UTF8(domain),
UTF8(initiatorIdentity) or empty,
UTF8(responderIdentity) or empty,
U8(initiatorKind or 0) || U8(responderKind or 0))
```
`ResponderProofCore` is a complete PPAP envelope of type `04` whose payload is
only:
```text
F(initiatorIdentityCiphertext or empty)
|| F(protectedInitiatorDescriptor or empty)
```
It omits the Finished field entirely; it does not append `F(empty)`. Its PPAP
payload length therefore differs from the transmitted `ResponderProof`.
```text
transcriptHash = H(FRAMES(
transcript-label,
authenticationContext,
full ClientHello envelope,
full ServerHello envelope,
full InitiatorProof envelope,
ResponderProofCore envelope))
ikm = FRAMES(
key-schedule-label,
ephemeralSecret,
initiatorIdentitySecret or empty,
responderIdentitySecret or empty)
sessionKey = HKDF(
ikm, transcriptHash,
FRAMES(session-key-label, authenticationContext), 32)
initiatorFinishedKey = HKDF(
ikm, transcriptHash,
FRAMES(initiator-finished-key-label, authenticationContext), 32)
responderFinishedKey = HKDF(
ikm, transcriptHash,
FRAMES(responder-finished-key-label, authenticationContext), 32)
initiatorFinished = MAC(
initiatorFinishedKey,
FRAMES(initiator-finished-label, transcriptHash))
responderFinished = MAC(
responderFinishedKey,
FRAMES(responder-finished-label, transcriptHash))
```
Finished values MUST be compared in constant time. A successfully returned
ML-KEM decapsulation alone MUST NOT be treated as authentication; the Finished
exchange confirms possession and the complete transcript.
### 5.3 Authentication state sequence
All three supported modes use the same ordered state sequence; only the field
presence from the preceding table differs.
| Step | Sender | PPAP message | EP carriage |
|---:|---|---|---|
| 1 | Initiator | ClientHello | `Initialize`, `SessionHeaders.AuthenticationData` |
| 2 | Responder | ServerHello | `ProceedToHandshake`, `SessionHeaders.AuthenticationData` |
| 3 | Initiator | InitiatorProof | `Handshake` action |
| 4 | Responder | ResponderProof | `Handshake` action |
| 5 | Initiator | InitiatorFinished | `FinalHandshake` action |
| 6 | Responder | no PPAP message | encrypted `Established` event |
The initiator validates the responder Finished before sending step 5. The
responder validates the initiator Finished before step 6. The session key,
local identity, and remote identity become handshake results only on successful
validation. PPAP still is not application-ready at this point: the protected
post-authentication phase in section 7 is mandatory.
## 6. Rotation messages and proof
The following are ordinary PPAP envelopes using the type codes in section 3.
All role fields MUST be initiator `01` or responder `02`.
```text
RotationStart payload =
U8(role)
RotationOffer payload =
U8(role)
|| F(UTF8(identity))
|| U64(expectedVersion)
|| F(newPublicKey[1184])
|| F(newPasswordDescriptor[62])
RotationChallenge payload =
U8(role)
|| F(ciphertext[1088])
RotationProof payload =
U8(role)
|| F(proof[32])
RotationCommit payload =
U8(role)
|| U64(committedVersion)
RotationCommitAck payload =
U8(role)
|| U64(committedVersion)
RotationDone payload = empty
```
For an offer, `expectedVersion` MUST be in `1..2^63-2`. The embedded descriptor
MUST be password-derived and its version MUST equal `expectedVersion + 1`.
Commit and acknowledgement versions MUST be at least two. The descriptor in an
offer is not protected by the descriptor AEAD from section 4.3; the complete
rotation exchange is instead REQUIRED to be inside the negotiated encrypted EP
transport.
The subject derives a new password key using a fresh 32-octet nonce, the same
identity/domain, and the unchanged KDF profile. The verifier encapsulates to the
offered public key, producing `challengeSecret` and the challenge ciphertext.
Using the exact, complete PPAP envelope octets as received or sent:
```text
rotationContext = H(FRAMES(
rotation-proof-label,
sessionKey,
full RotationOffer envelope,
full RotationChallenge envelope))
proof = MAC(challengeSecret, rotationContext)
```
The verifier MUST compare the proof in constant time. ML-KEM decapsulation does
not by itself validate a challenge.
### 6.1 Atomic registration commit
After proof verification, the verifier MUST perform one linearizable atomic
compare-and-swap keyed by the normalized domain and identity. A separate read
followed by an unconditional write is not conforming. The operation MUST return
failure without mutation unless all of these are true:
- the current record exists and its version equals `expectedVersion`;
- the replacement identity is exactly the same normalized identity under
ordinal comparison;
- the replacement version is exactly `expectedVersion + 1`;
- both records are password-derived;
- the Argon2 version, memory, iterations, and parallelism are unchanged;
- the new nonce is not reused; and
- the new encapsulation public key is not reused.
Material equality checks SHOULD be constant-time. At minimum, equality with the
current nonce and key MUST be rejected; a durable store that retains historical
material MUST also reject known historical reuse. A database implementation
MUST use a conditional update or transaction that provides the same CAS
semantics.
The verifier MUST NOT send `RotationCommit` unless the CAS succeeds. The subject
MUST retain its pending registration until it receives a commit for the exact
role and pending version, and only then adopt it. A CAS failure, version mismatch,
or concurrent update terminates the protocol rather than being retried in the
same session.
Consequently, when multiple successfully authenticated sessions concurrently
offer replacements for the same registration version, exactly one CAS can win.
The other sessions fail rotation and MUST begin a fresh authentication attempt
against the newly committed version before retrying.
The two role updates in `DualIdentity` mode are sequential CAS operations, not
one transaction spanning both records. A committed initiator-role update is not
rolled back if the later responder-role rotation fails; a retry starts from the
versions then present in the store.
## 7. Required post-authentication state sequence
A role requires a real rotation exactly when it was authenticated and its
registration kind is password-derived. Static identities are never rotated.
The initiator role is processed before the responder role. `I` and `R` below are
the connection initiator and responder; a parenthesized role is the registration
being rotated.
```text
No password-derived role:
I -> R RotationDone
Initiator role only:
I -> R RotationOffer(I)
R -> I RotationChallenge(I)
I -> R RotationProof(I)
R -> I RotationCommit(I) # after responder-side CAS
I -> R RotationDone
Responder role only:
I -> R RotationStart(R)
R -> I RotationOffer(R)
I -> R RotationChallenge(R)
R -> I RotationProof(R)
I -> R RotationCommit(R) # after initiator-side CAS
R -> I RotationCommitAck(R)
I -> R RotationDone
Both roles:
I -> R RotationOffer(I)
R -> I RotationChallenge(I)
I -> R RotationProof(I)
R -> I RotationCommit(I) # after responder-side CAS
I -> R RotationStart(R)
R -> I RotationOffer(R)
I -> R RotationChallenge(R)
R -> I RotationProof(R)
I -> R RotationCommit(R) # after initiator-side CAS
R -> I RotationCommitAck(R)
I -> R RotationDone
```
After accepting `RotationDone`, the responder queues the encrypted EP
`KeyRotationEstablished` event first, then sets `Session.Authenticated` and
publishes local readiness. Even when no password-derived role exists, the
encrypted `RotationDone`/`KeyRotationEstablished` exchange is mandatory. The
initiator MUST NOT publish readiness before receiving that event.
Messages for another role, duplicates, reordered messages, unexpected
acknowledgements, or messages received after completion MUST fail the exchange.
## 8. EP transport binding and encryption requirement
PPAP is registered only under the exact protocol string
`ppap-mlkem768-v1`; implementations MUST NOT silently accept aliases.
The EP method codes used by this suite are:
| Logical EP method | Code |
|---|---:|
| `Handshake` action | `80` |
| `FinalHandshake` action | `81` |
| `KeyRotation` action | `82` |
| `ProceedToHandshake` acknowledgement | `44` |
| `Established` event | `C0` |
| `ErrorTerminate` event | `C1` |
| `ErrorMustEncrypt` event | `C2` |
| `KeyRotationEstablished` event | `C9` |
The `Initialize` method octet, when its required headers TDU is present, is:
```text
20 | (authenticationMode << 2) | encryptionMode
```
The encryption-mode codes are `00` none, `01` session key, and `02` session key
plus addresses. PPAP MUST use `01` or `02`; `00` MUST be rejected because PPAP
always requires the protected post-authentication phase.
`Initialize` and `ProceedToHandshake` carry the PPAP envelope as the byte-array
value of indexed `SessionHeaders.AuthenticationData` (index 15), using the
standard EP indexed-structure codec. `SessionHeaders.AuthenticationProtocol`
(index 14) is the exact PPAP protocol name. The domain header (index 1) carries
the connection's configured domain; the PPAP handler normalizes that value to
NFC for its context. ClientHello carries the normalized domain, and the
responder compares the two normalized values.
For subsequent messages, a non-null PPAP envelope is encoded as an EP RawData
TDU. Since a PPAP envelope is 10 through 8192 octets, its exact direct carriage
is one of:
```text
U8(logicalMethod | 20) || 48 || U8(L) || ppapEnvelope # 10 <= L <= 255
U8(logicalMethod | 20) || 50 || U16(L) || ppapEnvelope # 256 <= L <= 8192
```
Here `20`, `48`, and `50` are hexadecimal octets and `U16` is unsigned
big-endian. Thus data-bearing Handshake, FinalHandshake, and KeyRotation method
octets are respectively `A0`, `A1`, and `A2`. A method without data is its lone
logical method octet; PPAP's final `KeyRotationEstablished` is therefore `C9`
before record protection.
The authentication messages through `InitiatorFinished` are sent before full
bidirectional transport protection. After verifying `InitiatorFinished`, the
responder enables encryption and sends `Established` as its first protected EP
message. The initiator must already accept protected inbound data; on receiving
that protected event it enables protected outbound data and begins rotation.
Every `KeyRotation` action, `RotationDone`, and `KeyRotationEstablished` event
MUST be encrypted. EP encrypted records are:
```text
U32(len(protectedPayload)) || protectedPayload
```
where `protectedPayload` is produced by the separately negotiated EP encryption
provider over the complete method/TDU plaintext. Its provider-specific format is
outside this PPAP suite. Receipt of rotation data while encryption is inactive
MUST terminate authentication. `Session.Authenticated` and application readiness
MUST remain false until rotation succeeds and readiness is published.
## 9. Interoperability and security invariants
A conforming implementation MUST fail closed on any of the following:
- wrong magic, wire version, expected message type, role, state, mode, or domain;
- a total, payload, field, key, ciphertext, mask, tag, proof, or Finished length
outside the exact bounds above;
- a declared length mismatch, integer outside the accepted range, truncated
input, or trailing input;
- an optional field whose presence does not exactly match the authentication
mode;
- invalid UTF-8, identity kind, KDF profile, descriptor marker, descriptor
combination, AEAD tag, or nonzero descriptor padding;
- an unresolved, ambiguous, or unexpected masked identity;
- a registration kind/profile/version/identity mismatch;
- a Finished, rotation proof, commit, acknowledgement, or CAS failure; or
- a rotation message outside active encrypted transport.
Proofs, Finished values, masked-identity candidates, and secret-derived material
comparisons MUST use constant-time equality. Implementations SHOULD erase
passwords, private keys, KEM secrets, Finished keys, derived AEAD material, and
pending rotation secrets as soon as their state no longer needs them. A handler
instance is single-connection state and MUST NOT be reused.
The clear authentication exchange carries only per-handshake masked identities;
it does not carry clear identity strings. A `RotationOffer` does contain its
normalized identity and descriptor, which is why rotation is forbidden outside
the encrypted EP transport. The deterministic descriptor nonce is safe only
under the one-descriptor-per-role-per-ephemeral-secret rule.
ML-KEM's implicit rejection behavior MUST remain indistinguishable at the
decapsulation API boundary. Only the transcript-bound Finished exchange or the
session-bound rotation proof establishes success. Expensive Argon2 work SHOULD
also be concurrency-limited so unauthenticated peers cannot exhaust local
memory.
### 9.1 Current local abuse controls (not wire protocol)
These controls do not change any PPAP octet and are not cross-language
interoperability requirements, but deployments need equivalent resource policy:
- The shared .NET password-derivation limiter allows
`max(1, min(processorCount, 4))` concurrent Argon2 operations. When more than
one processor is available, one slot is reserved for post-authentication
rotation. A saturated limiter rejects a derivation instead of queueing it.
- The default Warehouse connection-admission policy permits 120 connection
attempts per IP address in a one-minute window. Both values are
configurable, and setting the attempt limit to zero disables it.
- `InMemoryPpapRegistrationStore.ResolveMasked` deliberately scans the entire
store, evaluating candidates in the normalized domain, and is therefore
`O(N)` in total registrations per masked lookup. It is intended for tests and
small deployments. A large store needs a bounded or optimized lookup strategy
that still avoids persistent/stable masked tokens, uses constant-time
candidate comparison, and rejects ambiguity.
## 10. Implementation correspondence
The constants and encodings in this document are derived from these source
files:
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapCryptography.cs`
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapWire.cs`
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapRotationWire.cs`
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapModels.cs`
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapAuthenticationHandler.cs`
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapAuthenticationHandler.Rotation.cs`
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapAuthenticationProvider.cs`
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapPasswordDerivationLimiter.cs`
- `Libraries/Esiur/Security/Authority/AuthenticationMode.cs`
- `Libraries/Esiur/Security/Authority/Session.cs`
- `Libraries/Esiur/Security/Cryptography/EncryptionMode.cs`
- `Libraries/Esiur/Protocol/EpConnection.cs`
- `Libraries/Esiur/Protocol/EpConnectionProtocol.cs`
- `Libraries/Esiur/Protocol/EpServer.cs`
- `Libraries/Esiur/Net/Packets/EpAuthPacket.cs`
- `Libraries/Esiur/Net/Packets/EpAuthPacketMethod.cs`
- `Libraries/Esiur/Data/Tdu.cs`
- `Libraries/Esiur/Data/TduIdentifier.cs`
- `Libraries/Esiur/Resource/WarehouseConfiguration.cs`
+6 -2
View File
@@ -27,6 +27,7 @@ using Esiur.Data;
using Esiur.Protocol; using Esiur.Protocol;
using Esiur.Resource; using Esiur.Resource;
using Esiur.Security.Authority; using Esiur.Security.Authority;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography; using Esiur.Security.Cryptography;
using Esiur.Security.Management; using Esiur.Security.Management;
using Esiur.Security.RateLimiting; using Esiur.Security.RateLimiting;
@@ -120,7 +121,10 @@ internal static class Program
var server = await warehouse.Put("sys/server", new EpServer var server = await warehouse.Put("sys/server", new EpServer
{ {
Port = port, Port = port,
AllowedAuthenticationProviders = new[] { "hash" }, AllowedAuthenticationProviders = new[]
{
PasswordAuthenticationProvider.ProtocolName,
},
AllowedEncryptionProviders = new[] { AesEncryptionProvider.Name }, AllowedEncryptionProviders = new[] { AesEncryptionProvider.Name },
RequireEncryption = true, RequireEncryption = true,
}); });
@@ -185,7 +189,7 @@ internal static class Program
AuthenticationMode = AuthenticationMode.InitializerIdentity, AuthenticationMode = AuthenticationMode.InitializerIdentity,
AutoReconnect = false, AutoReconnect = false,
Identity = "tester", Identity = "tester",
AuthenticationProtocol = "hash", AuthenticationProtocol = PasswordAuthenticationProvider.ProtocolName,
Domain = "test", Domain = "test",
EncryptionMode = EncryptionMode.EncryptWithSessionKey, EncryptionMode = EncryptionMode.EncryptWithSessionKey,
EncryptionProviders = new[] { AesEncryptionProvider.Name }, EncryptionProviders = new[] { AesEncryptionProvider.Name },
+5 -4
View File
@@ -1,6 +1,7 @@
using System.Reflection; using System.Reflection;
using System.Security.Cryptography; using System.Security.Cryptography;
using Esiur.Security.Authority; using Esiur.Security.Authority;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography; using Esiur.Security.Cryptography;
namespace Esiur.Tests.Unit; namespace Esiur.Tests.Unit;
@@ -26,7 +27,7 @@ public class AesEncryptionProviderTests
var record = initiator.Encrypt("Esiur AES-GCM vector"u8.ToArray()); var record = initiator.Encrypt("Esiur AES-GCM vector"u8.ToArray());
Assert.Equal( Assert.Equal(
"0000000000000000BF5D9D7DA3D6D5232578F966450E5B96B89172D4F186A5C45299C42D8ABB732A07B1E092", "0000000000000000D9538F565DFB97D51FE9AA53316A1FE2D5EEF3361A8958E4439C0BE21A6FC840A3DF42F9",
Convert.ToHexString(record)); Convert.ToHexString(record));
} }
@@ -145,8 +146,8 @@ public class AesEncryptionProviderTests
} }
[Theory] [Theory]
[InlineData(AuthenticationMode.DualIdentity, "hash")] [InlineData(AuthenticationMode.DualIdentity, "password-sha3-v1")]
[InlineData(AuthenticationMode.InitializerIdentity, "hash-v2")] [InlineData(AuthenticationMode.InitializerIdentity, "password-sha3-v2")]
public void AuthenticationNegotiation_IsBoundIntoKeyDerivation( public void AuthenticationNegotiation_IsBoundIntoKeyDerivation(
AuthenticationMode responderMode, AuthenticationMode responderMode,
string responderProtocol) string responderProtocol)
@@ -242,7 +243,7 @@ public class AesEncryptionProviderTests
string protocol = AesEncryptionProvider.Name, string protocol = AesEncryptionProvider.Name,
string[]? offeredProtocols = null, string[]? offeredProtocols = null,
AuthenticationMode authenticationMode = AuthenticationMode.InitializerIdentity, AuthenticationMode authenticationMode = AuthenticationMode.InitializerIdentity,
string authenticationProtocol = "hash", string authenticationProtocol = PasswordAuthenticationProvider.ProtocolName,
string domain = "example.test") string domain = "example.test")
=> (AesGcmSymetricCipher)_provider.CreateCipher(new EncryptionContext => (AesGcmSymetricCipher)_provider.CreateCipher(new EncryptionContext
{ {
+16
View File
@@ -82,6 +82,22 @@ public class AuthHandshakeTests
// ---- happy path ------------------------------------------------------------------ // ---- 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] [Fact]
public void InitializerIdentity_Handshake_Derives_Matching_SessionKeys() public void InitializerIdentity_Handshake_Derives_Matching_SessionKeys()
{ {
+4 -2
View File
@@ -4,6 +4,7 @@ using Esiur.Data;
using Esiur.Net.Packets; using Esiur.Net.Packets;
using Esiur.Resource; using Esiur.Resource;
using Esiur.Security.Authority; using Esiur.Security.Authority;
using Esiur.Security.Authority.Providers;
namespace Esiur.Tests.Unit; namespace Esiur.Tests.Unit;
@@ -93,7 +94,7 @@ public class IndexedStructureTests
SupportedCiphers = new[] { "aes-gcm" }, SupportedCiphers = new[] { "aes-gcm" },
CipherType = "aes-gcm", CipherType = "aes-gcm",
IPAddress = new byte[] { 127, 0, 0, 1 }, IPAddress = new byte[] { 127, 0, 0, 1 },
AuthenticationProtocol = "hash", AuthenticationProtocol = PasswordAuthenticationProvider.ProtocolName,
AuthenticationData = new byte[] { 1, 2, 3 }, AuthenticationData = new byte[] { 1, 2, 3 },
CipherNonce = Enumerable.Range(0, 32).Select(x => (byte)x).ToArray(), CipherNonce = Enumerable.Range(0, 32).Select(x => (byte)x).ToArray(),
}; };
@@ -116,7 +117,8 @@ public class IndexedStructureTests
Assert.Equal(legacyBytes, bytes); Assert.Equal(legacyBytes, bytes);
Assert.Equal("example.test", map[(byte)EpAuthPacketHeader.Domain]); 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.False(map.ContainsKey((byte)EpAuthPacketHeader.ErrorMessage));
Assert.Equal(source.Domain, parsed.Domain); Assert.Equal(source.Domain, parsed.Domain);
Assert.Equal(source.IPAddress, parsed.IPAddress); 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;
using System.Net;
using System.Net.Sockets;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Esiur.Core; using Esiur.Core;
@@ -11,7 +13,7 @@ using Esiur.Stores;
namespace Esiur.Tests.Unit.Integration; 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) ------------------------------ // == {1..10}, which is what the server stores the hash of) ------------------------------
internal class TestServerAuthProvider : PasswordAuthenticationProvider internal class TestServerAuthProvider : PasswordAuthenticationProvider
@@ -38,33 +40,53 @@ internal class TestClientAuthProvider : PasswordAuthenticationProvider
internal sealed class OneStepAuthenticationProvider : IAuthenticationProvider internal sealed class OneStepAuthenticationProvider : IAuthenticationProvider
{ {
readonly byte _keyMarker; readonly byte _keyMarker;
readonly bool _requiresKeyRotation;
public OneStepAuthenticationProvider(byte keyMarker = 0) public OneStepAuthenticationProvider(byte keyMarker = 0, bool requiresKeyRotation = false)
=> _keyMarker = keyMarker; {
_keyMarker = keyMarker;
_requiresKeyRotation = requiresKeyRotation;
}
public string DefaultName => "one-step"; public string DefaultName => "one-step";
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context) 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> Login(Session session) => new AsyncReply<bool>(true);
public AsyncReply<bool> Logout(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(); static readonly byte[] SharedKey = Enumerable.Range(1, 64).Select(x => (byte)x).ToArray();
readonly OneStepAuthenticationProvider _provider; readonly OneStepAuthenticationProvider _provider;
readonly byte _keyMarker; 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; _provider = provider;
_keyMarker = keyMarker; _keyMarker = keyMarker;
_direction = direction;
RequiresKeyRotation = requiresKeyRotation;
} }
public IAuthenticationProvider Provider => _provider; public IAuthenticationProvider Provider => _provider;
public string Protocol => _provider.DefaultName; public string Protocol => _provider.DefaultName;
public bool RequiresKeyRotation { get; }
public bool KeyRotationCompleted { get; private set; }
public AuthenticationResult Process(object authData) public AuthenticationResult Process(object authData)
{ {
@@ -77,6 +99,49 @@ internal sealed class OneStepAuthenticationHandler : IAuthenticationHandler
"server", "server",
key); 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> /// <summary>
@@ -88,6 +153,29 @@ internal sealed class IntegrationCluster : IAsyncDisposable
{ {
static int _portCounter = 14400; 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 ServerWarehouse { get; }
public Warehouse ClientWarehouse { get; } public Warehouse ClientWarehouse { get; }
public EpServer Server { get; } public EpServer Server { get; }
@@ -115,15 +203,16 @@ internal sealed class IntegrationCluster : IAsyncDisposable
bool oneStepAuthentication = false, bool oneStepAuthentication = false,
bool useWebSocket = false, bool useWebSocket = false,
bool mismatchedSessionKeys = false, bool mismatchedSessionKeys = false,
bool requireKeyRotation = false,
bool allowAuthentication = true, bool allowAuthentication = true,
bool registerServerAuthenticationProvider = true) bool registerServerAuthenticationProvider = true)
{ {
var port = Interlocked.Increment(ref _portCounter); var port = NextAvailablePort();
var serverWh = new Warehouse(); var serverWh = new Warehouse();
if (registerServerAuthenticationProvider) if (registerServerAuthenticationProvider)
serverWh.RegisterAuthenticationProvider(oneStepAuthentication serverWh.RegisterAuthenticationProvider(oneStepAuthentication
? new OneStepAuthenticationProvider() ? new OneStepAuthenticationProvider(requiresKeyRotation: requireKeyRotation)
: new TestServerAuthProvider()); : new TestServerAuthProvider());
if (encrypted || requireEncryption) if (encrypted || requireEncryption)
serverWh.RegisterEncryptionProvider(new AesEncryptionProvider()); serverWh.RegisterEncryptionProvider(new AesEncryptionProvider());
@@ -133,7 +222,12 @@ internal sealed class IntegrationCluster : IAsyncDisposable
{ {
Port = (ushort)port, Port = (ushort)port,
AllowedAuthenticationProviders = allowAuthentication AllowedAuthenticationProviders = allowAuthentication
? new[] { oneStepAuthentication ? "one-step" : "hash" } ? new[]
{
oneStepAuthentication
? "one-step"
: PasswordAuthenticationProvider.ProtocolName,
}
: Array.Empty<string>(), : Array.Empty<string>(),
AllowedEncryptionProviders = (encrypted || requireEncryption) && allowEncryption AllowedEncryptionProviders = (encrypted || requireEncryption) && allowEncryption
? new[] { AesEncryptionProvider.Name } ? new[] { AesEncryptionProvider.Name }
@@ -148,7 +242,9 @@ internal sealed class IntegrationCluster : IAsyncDisposable
var cluster = new IntegrationCluster(serverWh, server, port); var cluster = new IntegrationCluster(serverWh, server, port);
cluster.ClientWarehouse.RegisterAuthenticationProvider(oneStepAuthentication cluster.ClientWarehouse.RegisterAuthenticationProvider(oneStepAuthentication
? new OneStepAuthenticationProvider(mismatchedSessionKeys ? (byte)0x80 : (byte)0) ? new OneStepAuthenticationProvider(
mismatchedSessionKeys ? (byte)0x80 : (byte)0,
requireKeyRotation)
: new TestClientAuthProvider()); : new TestClientAuthProvider());
if (encrypted) if (encrypted)
cluster.ClientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider()); cluster.ClientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
@@ -161,7 +257,9 @@ internal sealed class IntegrationCluster : IAsyncDisposable
{ {
AuthenticationMode = AuthenticationMode.InitializerIdentity, AuthenticationMode = AuthenticationMode.InitializerIdentity,
Identity = "tester", Identity = "tester",
AuthenticationProtocol = oneStepAuthentication ? "one-step" : "hash", AuthenticationProtocol = oneStepAuthentication
? "one-step"
: PasswordAuthenticationProvider.ProtocolName,
Domain = "test", Domain = "test",
EncryptionMode = encrypted EncryptionMode = encrypted
? encryptionMode ? 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.Data;
using Esiur.Net.Sockets; using Esiur.Net.Sockets;
using Esiur.Protocol; using Esiur.Protocol;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography; using Esiur.Security.Cryptography;
[Collection("Integration")] [Collection("Integration")]
@@ -21,7 +22,8 @@ public class SessionHeadersIntegrationTests
Assert.Null(cluster.Connection.Session.RemoteHeaders.AuthenticationData); Assert.Null(cluster.Connection.Session.RemoteHeaders.AuthenticationData);
Assert.Equal("test", serverConnection.Session.RemoteHeaders.Domain); 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); Assert.Null(serverConnection.Session.RemoteHeaders.AuthenticationData);
} }
@@ -159,6 +161,50 @@ public class SessionHeadersIntegrationTests
Assert.Equal(117, Convert.ToInt32(result)); 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] [Fact]
public async Task WebSocketTransport_EncryptsFetchAndRpc() public async Task WebSocketTransport_EncryptsFetchAndRpc()
{ {
+97
View File
@@ -49,6 +49,103 @@ public class PeerConnectionLimitTests
replacementSocket.Close(); 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 sealed class TestSocket : ISocket
{ {
public event DestroyedEvent? OnDestroy; 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);
}
}
}