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
Handshake = 0x80,
FinalHandshake = 0x81,
KeyRotation = 0x82,
// Acks
Denied = 0x40, // no reason, terminate connection
@@ -33,6 +34,7 @@ namespace Esiur.Net.Packets
ErrorRetry = 0xC3,
IndicationEstablished = 0xC8,
KeyRotationEstablished = 0xC9,
IAuthPlain = 0xD0,
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;
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;
ushort _port;
@@ -360,7 +370,10 @@ public partial class EpConnection : NetworkConnection, IStore
if (socket.State == SocketState.Established &&
_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)
return;
if (RequiresAuthenticationKeyRotation()
&& _session.EncryptionMode == EncryptionMode.None)
{
RejectEncryption("Authentication key rotation requires encrypted transport protection.");
return;
}
if (_session.EncryptionMode != EncryptionMode.None)
{
try
@@ -401,7 +421,7 @@ public partial class EpConnection : NetworkConnection, IStore
if (initAuthResult.Ruling == AuthenticationRuling.Succeeded)
{
SetSessionKey(initAuthResult.SessionKey);
AdoptSessionKey(initAuthResult);
_session.LocalIdentity = initAuthResult.LocalIdentity;
_session.RemoteIdentity = initAuthResult.RemoteIdentity;
}
@@ -535,6 +555,173 @@ public partial class EpConnection : NetworkConnection, IStore
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()
{
if (_session.EncryptionMode == EncryptionMode.None || _session.SymetricCipher != null)
@@ -636,6 +823,21 @@ public partial class EpConnection : NetworkConnection, IStore
_session.Key = replacement;
}
void AdoptSessionKey(AuthenticationResult result)
{
if (result == null)
throw new ArgumentNullException(nameof(result));
try
{
SetSessionKey(result.SessionKey);
}
finally
{
result.ClearSessionKey();
}
}
void CompletePendingOpen()
{
var pending = Interlocked.Exchange(ref _openReply, null);
@@ -675,11 +877,185 @@ public partial class EpConnection : NetworkConnection, IStore
_outboundProtectionState = OutboundProtectionState.Plaintext;
_decryptInbound = false;
_decryptedReceiveBuffer = new NetworkBuffer();
_authenticationHandshakeSucceeded = false;
_authenticationKeyRotationStarted = false;
_authenticationKeyRotationSucceeded = false;
if (_session != null)
_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()
{
lock (_encryptionSendLock)
@@ -849,10 +1225,18 @@ public partial class EpConnection : NetworkConnection, IStore
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()
{
TerminateInvocations();
UnsubscribeAll();
EndAuthenticationAttempt();
DisposeAuthenticationHandler();
DisposeSessionEncryption();
this.OnReady = null;
this.OnError = null;
@@ -1240,6 +1624,13 @@ public partial class EpConnection : NetworkConnection, IStore
// set auth handler for the session
_session.AuthenticationHandler = handler;
if (RequiresAuthenticationKeyRotation()
&& _session.EncryptionMode == EncryptionMode.None)
{
RejectEncryption("Authentication key rotation requires encrypted transport protection.");
return offset;
}
var authResult = handler.Process(remoteAuthData);
@@ -1260,10 +1651,10 @@ public partial class EpConnection : NetworkConnection, IStore
}
else if (authResult.Ruling == AuthenticationRuling.Succeeded)
{
_session.Authenticated = true;
MarkAuthenticationHandshakeSucceeded();
_session.LocalIdentity = authResult.LocalIdentity;
_session.RemoteIdentity = authResult.RemoteIdentity;
SetSessionKey(authResult.SessionKey);
AdoptSessionKey(authResult);
try
{
@@ -1275,7 +1666,7 @@ public partial class EpConnection : NetworkConnection, IStore
return offset;
}
AuthenticatonCompleted(() =>
Action establishSession = () =>
{
// The initiator needs the selected provider and responder nonce
// before it can construct its cipher, so this acknowledgement is
@@ -1288,7 +1679,12 @@ public partial class EpConnection : NetworkConnection, IStore
}
else
SendAuthHeaders(EpAuthPacketMethod.SessionEstablished, localHeaders);
});
};
if (RequiresAuthenticationKeyRotation())
establishSession();
else
AuthenticatonCompleted(establishSession);
}
}
else if (_authPacket.Command == EpAuthPacketCommand.Acknowledge)
@@ -1341,12 +1737,12 @@ public partial class EpConnection : NetworkConnection, IStore
return offset;
}
SetSessionKey(authResult.SessionKey);
AdoptSessionKey(authResult);
_session.LocalIdentity = authResult.LocalIdentity;
_session.RemoteIdentity = authResult.RemoteIdentity;
}
_session.Authenticated = true;
MarkAuthenticationHandshakeSucceeded();
try
{
@@ -1428,14 +1824,22 @@ public partial class EpConnection : NetworkConnection, IStore
}
else if (authResult.Ruling == AuthenticationRuling.Succeeded)
{
_session.Authenticated = true;
SetSessionKey(authResult.SessionKey);
MarkAuthenticationHandshakeSucceeded();
AdoptSessionKey(authResult);
_session.LocalIdentity = authResult.LocalIdentity;
_session.RemoteIdentity = authResult.RemoteIdentity;
try
{
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)
{
@@ -1447,9 +1851,6 @@ public partial class EpConnection : NetworkConnection, IStore
SendAuthData(EpAuthPacketMethod.FinalHandshake,
authResult.AuthenticationData);
if (_session.EncryptionMode != EncryptionMode.None)
EnableInboundEncryption();
//if (_authPacket.Method == EpAuthPacketMethod.SessionEstablished)
//{
// AuthenticatonCompleted(authResult.LocalIdentity, authResult.RemoteIdentity);
@@ -1470,7 +1871,7 @@ public partial class EpConnection : NetworkConnection, IStore
var errorMessage = "Authentication error.";
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)
errorMessage = parsedErrorMsg;
}
@@ -1491,7 +1892,7 @@ public partial class EpConnection : NetworkConnection, IStore
if (_authPacket.Tdu != null)
{
var parsed = Codec.ParseSync(_authPacket.Tdu.Value, _serverWarehouse);
var parsed = Codec.ParseSync(_authPacket.Tdu.Value, ParsingWarehouse);
authData = parsed;
}
@@ -1512,8 +1913,8 @@ public partial class EpConnection : NetworkConnection, IStore
}
else if (authResult.Ruling == AuthenticationRuling.Succeeded)
{
_session.Authenticated = true;
SetSessionKey(authResult.SessionKey);
MarkAuthenticationHandshakeSucceeded();
AdoptSessionKey(authResult);
_session.LocalIdentity = authResult.LocalIdentity;
_session.RemoteIdentity = authResult.RemoteIdentity;
@@ -1530,9 +1931,7 @@ public partial class EpConnection : NetworkConnection, IStore
return offset;
}
// Registration and receive readiness must complete before the
// initiator is allowed to send its first application request.
AuthenticatonCompleted(() =>
Action establishSession = () =>
{
if (_session.EncryptionMode != EncryptionMode.None)
{
@@ -1554,7 +1953,41 @@ public partial class EpConnection : NetworkConnection, IStore
authResult.AuthenticationData);
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)
{
@@ -1564,6 +1997,10 @@ public partial class EpConnection : NetworkConnection, IStore
}
}
else if (_authPacket.Method == EpAuthPacketMethod.KeyRotation)
{
ProcessAuthenticationKeyRotation(authData);
}
}
else if (_authPacket.Command == EpAuthPacketCommand.Event)
{
@@ -1574,7 +2011,7 @@ public partial class EpConnection : NetworkConnection, IStore
var errorMessage = "Authentication error.";
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)
errorMessage = parsedErrorMsg;
}
@@ -1590,12 +2027,15 @@ public partial class EpConnection : NetworkConnection, IStore
}
else if (_authPacket.Method == EpAuthPacketMethod.Established)
{
if (_session.Authenticated)
if (_authenticationHandshakeSucceeded)
{
if (_session.EncryptionMode != EncryptionMode.None)
EnableEncryption();
AuthenticatonCompleted();
if (RequiresAuthenticationKeyRotation())
BeginAuthenticationKeyRotation();
else
AuthenticatonCompleted();
}
else
{
@@ -1605,6 +2045,23 @@ public partial class EpConnection : NetworkConnection, IStore
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)
{
// @TODO: handle multi-factor authentication indication
@@ -1622,6 +2079,7 @@ public partial class EpConnection : NetworkConnection, IStore
void AuthenticatonCompleted(Action beforeReady = null)
{
var generation = Volatile.Read(ref _authenticationAttemptGeneration);
if (this.Instance == null)
{
@@ -1629,10 +2087,31 @@ public partial class EpConnection : NetworkConnection, IStore
Server.Instance.Link + "/" + this.GetHashCode().ToString().Replace("/", "_"), this)
.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;
CompletePendingOpen();
OnReady?.Invoke(this);
@@ -1643,13 +2122,32 @@ public partial class EpConnection : NetworkConnection, IStore
}).Error(x =>
{
FailPendingOpen(x);
if (generation == Volatile.Read(ref _authenticationAttemptGeneration))
{
FailPendingOpen(x);
Close();
}
});
}
else
{
_authenticated = true;
beforeReady?.Invoke();
if (!TryPublishAuthenticationReady(
generation,
beforeReady,
out var publicationError))
{
if (publicationError != null)
{
Global.Log("EpConnection:AuthenticationReady", LogType.Warning,
publicationError.ToString());
FailPendingOpen(publicationError);
Close();
}
return;
}
CancelAuthenticationDeadline();
Status = EpConnectionStatus.Connected;
_session.AuthenticationHandler?.Provider?.Login(_session);
@@ -2583,27 +3081,28 @@ public partial class EpConnection : NetworkConnection, IStore
_remoteDomain = epContext.Domain ?? address;
if (provider != null)
{
_session.AuthenticationHandler = provider.CreateAuthenticationHandler(new AuthenticationContext()
_authenticationProvider = provider;
_authenticationContext = provider == null
? null
: new AuthenticationContext()
{
Direction = AuthenticationDirection.Initiator,
Domain = _remoteDomain,
HostName = address,
InitiatorIdentity = epContext.Identity,
ResponderIdentity = epContext.ResponderIdentity,
Mode = epContext.AuthenticationMode,
});
}
};
_session.AuthenticationMode = epContext.AuthenticationMode;
_session.EncryptionMode = epContext.EncryptionMode;
_offeredEncryptionProviders = epContext.EncryptionProviders ?? Array.Empty<string>();
_session.LocalIdentity = epContext.Identity;
ReconnectInterval = epContext.ReconnectInterval;
AuthenticationTimeout = epContext.AuthenticationTimeout;
ExceptionLevel = epContext.ExceptionLevel;
UseWebSocket = epContext.UseWebSocket;
SecureWebSocket = epContext.SecureWebSocket;
_remoteDomain = epContext.Domain;
AutoReconnect = epContext.AutoReconnect;
_hostname = address;
_port = port;
@@ -2653,6 +3152,13 @@ public partial class EpConnection : NetworkConnection, IStore
if (_session == null)
throw new AsyncException(ErrorType.Exception, 0, "Session not initialized");
if (_authenticationProvider != null && _authenticationContext != null)
{
DisposeAuthenticationHandler();
_session.AuthenticationHandler =
_authenticationProvider.CreateAuthenticationHandler(_authenticationContext);
}
BeginPlaintextHandshake();
if (socket == null)
@@ -2680,7 +3186,8 @@ public partial class EpConnection : NetworkConnection, IStore
if (AutoReconnect)
{
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
{
@@ -2800,6 +3307,10 @@ public partial class EpConnection : NetworkConnection, IStore
protected override void Connected()
{
lock (_authenticationLifecycleLock)
Interlocked.Increment(ref _authenticationAttemptGeneration);
RestartAuthenticationDeadline();
if (_authDirection == AuthenticationDirection.Initiator)
Declare();
}
@@ -2808,9 +3319,11 @@ public partial class EpConnection : NetworkConnection, IStore
{
// clean up
var wasAuthenticated = _authenticated || (_session?.Authenticated ?? false);
EndAuthenticationAttempt();
TerminateInvocations();
DisposeSessionEncryption();
_authenticated = false;
_authenticationHandshakeSucceeded = false;
if (_session != null)
_session.Authenticated = false;
Status = EpConnectionStatus.Closed;
@@ -2890,15 +3403,24 @@ public partial class EpConnection : NetworkConnection, IStore
//Server.Membership?.Logout(_session);
}
}
else if (AutoReconnect && !_invalidCredentials)
{
// reconnect
Task.Delay((int)ReconnectInterval).ContinueWith((x) => Reconnect());
DisposeAuthenticationHandler();
}
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.Resource;
using Esiur.Security.Authority;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography;
using Esiur.Security.Membership;
using Esiur.Security.Permissions;
@@ -39,7 +40,16 @@ public class EpConnectionContext : IResourceContext
public AuthenticationMode AuthenticationMode { get; set; } = AuthenticationMode.None;
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>
/// Controls whether the authenticated session key must protect EP traffic.
@@ -53,8 +63,15 @@ public class EpConnectionContext : IResourceContext
public bool AutoReconnect { get; set; } = false;
/// <summary>Delay between reconnect attempts, in seconds.</summary>
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 bool UseWebSocket { get; set; }
+61 -4
View File
@@ -44,9 +44,18 @@ namespace Esiur.Protocol;
public class EpServer : NetworkServer<EpConnection>, IResource
{
sealed class PeerAttemptWindow
{
public DateTime StartedUtc;
public int Count;
}
readonly object _peerConnectionsLock = new object();
readonly Dictionary<IPAddress, int> _peerConnectionCounts = new Dictionary<IPAddress, int>();
readonly Dictionary<EpConnection, IPAddress> _admittedConnections = new Dictionary<EpConnection, IPAddress>();
readonly Dictionary<IPAddress, PeerAttemptWindow> _peerAttemptWindows =
new Dictionary<IPAddress, PeerAttemptWindow>();
uint _attemptSweepSequence;
//[Attribute]
@@ -75,6 +84,12 @@ public class EpServer : NetworkServer<EpConnection>, IResource
/// </summary>
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]
public bool AllowUnauthorizedAccess { get; set; }
@@ -180,12 +195,12 @@ public class EpServer : NetworkServer<EpConnection>, IResource
public override void Add(EpConnection connection)
{
if (!TryAdmitConnection(connection))
if (!TryAdmitConnection(connection, out var rejectionReason))
{
Global.Log(
"EpServer:ConnectionLimit",
LogType.Warning,
$"Rejected connection from {connection.RemoteEndPoint?.Address}: per-IP limit reached.");
$"Rejected connection from {connection.RemoteEndPoint?.Address}: {rejectionReason}");
connection.Close();
return;
}
@@ -200,6 +215,8 @@ public class EpServer : NetworkServer<EpConnection>, IResource
});
connection.ExceptionLevel = ExceptionLevel;
connection.AuthenticationTimeout = AuthenticationTimeout;
connection.RestartAuthenticationDeadline();
base.Add(connection);
}
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);
if (address == null)
return true;
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)
? current
: 0;
var limit = Instance.Warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress;
var limit = configuration.MaximumConnectionsPerIpAddress;
if (limit > 0 && count >= limit)
{
rejectionReason = "per-IP concurrent-connection limit reached";
return false;
}
_peerConnectionCounts[address] = count + 1;
_admittedConnections[connection] = address;
@@ -66,6 +66,15 @@ public sealed class ResourceAttachmentConfiguration
public sealed class ConnectionConfiguration
{
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>
@@ -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
{
Hash = 0,
Password = 0,
[Obsolete("Use Password instead.")]
Hash = Password,
PPAP = 1,
}
}
@@ -26,7 +26,17 @@ namespace Esiur.Security.Authority
LocalIdentity = localIdentity;
RemoteIdentity = remoteIdentity;
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
{
/// <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
/// 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
@@ -18,7 +19,7 @@ namespace Esiur.Security.Authority.Providers
/// </summary>
public class PasswordAuthenticationHandler : IAuthenticationHandler
{
public string Protocol => "hash";
public string Protocol => PasswordAuthenticationProvider.ProtocolName;
// Length, in bytes, of the random nonces exchanged during the handshake.
// 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 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)
{
@@ -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.");
}
}