mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
PPAP integration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
+453
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
# PPAP ML-KEM-768 v1
|
||||
|
||||
## Status and scope
|
||||
|
||||
This document specifies the byte-for-byte interoperable form of Esiur's fixed
|
||||
PPAP suite named `ppap-mlkem768-v1`, including its required encrypted
|
||||
post-authentication registration rotation. It describes the protocol currently
|
||||
implemented by `PpapWire`, `PpapCryptography`, `PpapAuthenticationHandler`, and
|
||||
`EpConnection`.
|
||||
|
||||
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, and **SHOULD NOT**
|
||||
are normative requirements. There is no algorithm negotiation inside PPAP v1.
|
||||
|
||||
## 1. Notation and canonical encodings
|
||||
|
||||
- `A || B` is octet concatenation.
|
||||
- `U8(n)` is one unsigned octet.
|
||||
- `U32(n)` is an unsigned 32-bit integer in big-endian order. The current wire
|
||||
reader accepts only `0..2^31-1`; values with the high bit set are invalid.
|
||||
- `U64(n)` is an unsigned 64-bit integer in big-endian order. The implementation
|
||||
uses a non-negative signed 64-bit value, so the accepted range is
|
||||
`0..2^63-1`.
|
||||
- `F(x)` is `U32(len(x)) || x`.
|
||||
- `FRAMES(x1, ..., xn)` is `F(x1) || ... || F(xn)`. A null or absent input is
|
||||
encoded as a zero-length field, `00 00 00 00`.
|
||||
- `H(x)` is SHA3-256 over `x`.
|
||||
- `MAC(k, x)` is HMAC-SHA3-256 with key `k` over `x`.
|
||||
- `HKDF(ikm, salt, info, L)` is RFC 5869 HKDF using SHA3-256 and producing `L`
|
||||
octets.
|
||||
- `UTF8(s)` is UTF-8 without a BOM. Encoders and decoders MUST reject invalid
|
||||
Unicode/UTF-8. Identities and domains MUST be normalized to Unicode NFC before
|
||||
UTF-8 encoding and before ordinal comparison.
|
||||
|
||||
An identity MUST be non-null, non-empty, and not entirely whitespace. A domain
|
||||
may be empty; a null domain is the empty domain. An encoded identity or domain
|
||||
MUST NOT exceed 512 octets. Implementations MUST use the canonical encodings in
|
||||
this section even when their host language has a different default byte order,
|
||||
text encoding, or string comparison.
|
||||
|
||||
## 2. Fixed suite
|
||||
|
||||
| Parameter | Required value |
|
||||
|---|---:|
|
||||
| Protocol name | `ppap-mlkem768-v1` |
|
||||
| PPAP wire version | `1` |
|
||||
| KEM | ML-KEM-768 |
|
||||
| ML-KEM public key | 1184 octets |
|
||||
| ML-KEM private-key encoding | 2400 octets |
|
||||
| ML-KEM ciphertext | 1088 octets |
|
||||
| ML-KEM shared secret | 32 octets |
|
||||
| Deterministic ML-KEM seed | 64 octets |
|
||||
| Hash/MAC/Finished | 32 octets |
|
||||
| Identity mask and registration nonce | 32 octets each |
|
||||
| Session key | 32 octets |
|
||||
| Descriptor AEAD | AES-256-GCM, 128-bit tag |
|
||||
| Descriptor plaintext/key/nonce/tag | 128/32/12/16 octets |
|
||||
| Protected descriptor | 144 octets |
|
||||
| Maximum password | 4096 octets, non-empty |
|
||||
| Maximum complete PPAP envelope | 8192 octets |
|
||||
|
||||
ML-KEM key parsing MUST validate the encoded key as well as its exact length.
|
||||
ML-KEM encapsulation randomness and every randomly generated ephemeral/static
|
||||
key seed, identity mask, and registration nonce MUST come from a
|
||||
cryptographically secure random source.
|
||||
|
||||
### 2.1 Domain-separation labels
|
||||
|
||||
Every label below is the exact sequence of displayed ASCII octets, without a
|
||||
terminating NUL:
|
||||
|
||||
| Purpose | ASCII label |
|
||||
|---|---|
|
||||
| Password seed | `esiur/ppap-mlkem768-v1/password-seed` |
|
||||
| Masked identity | `esiur/ppap-mlkem768-v1/masked-identity` |
|
||||
| Descriptor salt | `esiur/ppap-mlkem768-v1/descriptor-salt` |
|
||||
| Descriptor key | `esiur/ppap-mlkem768-v1/descriptor-key` |
|
||||
| Descriptor nonce | `esiur/ppap-mlkem768-v1/descriptor-nonce` |
|
||||
| Descriptor AAD | `esiur/ppap-mlkem768-v1/descriptor-aad` |
|
||||
| Initiator descriptor role | `esiur/ppap-mlkem768-v1/initiator-registration` |
|
||||
| Responder descriptor role | `esiur/ppap-mlkem768-v1/responder-registration` |
|
||||
| Transcript | `esiur/ppap-mlkem768-v1/transcript` |
|
||||
| Key-schedule input | `esiur/ppap-mlkem768-v1/key-schedule` |
|
||||
| Session-key info | `esiur/ppap-mlkem768-v1/session-key` |
|
||||
| Initiator Finished-key info | `esiur/ppap-mlkem768-v1/initiator-finished-key` |
|
||||
| Responder Finished-key info | `esiur/ppap-mlkem768-v1/responder-finished-key` |
|
||||
| Initiator Finished | `esiur/ppap-mlkem768-v1/initiator-finished` |
|
||||
| Responder Finished | `esiur/ppap-mlkem768-v1/responder-finished` |
|
||||
| Rotation proof | `esiur/ppap-mlkem768-v1/rotation-proof` |
|
||||
|
||||
## 3. Codes and PPAP envelope
|
||||
|
||||
All numeric codes in this section are hexadecimal. Authentication modes use
|
||||
these exact octets:
|
||||
|
||||
| Code | Name | Authenticated role(s) |
|
||||
|---:|---|---|
|
||||
| `01` | `InitializerIdentity` | Initiator |
|
||||
| `02` | `ResponderIdentity` | Responder |
|
||||
| `03` | `DualIdentity` | Initiator and responder |
|
||||
|
||||
Mode `00` (`None`) is not supported by PPAP. The code spelling
|
||||
`InitializerIdentity` is retained for compatibility; it means the connection
|
||||
initiator.
|
||||
|
||||
Identity roles are initiator `01` and responder `02`. Identity kinds are
|
||||
password-derived `01` and static ML-KEM `02`.
|
||||
|
||||
Every PPAP message is encoded as:
|
||||
|
||||
```text
|
||||
50 50 41 50 || 01 || U8(messageType) || U32(payloadLength) || payload
|
||||
```
|
||||
|
||||
`50 50 41 50` is ASCII `PPAP`. The total envelope MUST be between 10 and 8192
|
||||
octets, so the payload is at most 8182 octets. The declared payload length MUST
|
||||
equal the exact remaining length, and trailing data is forbidden.
|
||||
|
||||
| Type | Code | Type | Code |
|
||||
|---|---:|---|---:|
|
||||
| ClientHello | `01` | RotationStart | `10` |
|
||||
| ServerHello | `02` | RotationOffer | `11` |
|
||||
| InitiatorProof | `03` | RotationChallenge | `12` |
|
||||
| ResponderProof | `04` | RotationProof | `13` |
|
||||
| InitiatorFinished | `05` | RotationCommit | `14` |
|
||||
| | | RotationCommitAck | `15` |
|
||||
| | | RotationDone | `16` |
|
||||
|
||||
All variable fields below use `F(value)`, including absent fields. A decoder
|
||||
MUST enforce each field's exact required size and MUST reject trailing payload
|
||||
octets.
|
||||
|
||||
## 4. Registration material
|
||||
|
||||
### 4.1 Password-derived ML-KEM key
|
||||
|
||||
The default KDF profile is Argon2id version `0x13` (1.3), memory 32768 KiB,
|
||||
three iterations, and parallelism one. An accepted profile MUST satisfy all of:
|
||||
|
||||
- version is exactly `0x13`;
|
||||
- memory is 8192 through 262144 KiB, inclusive;
|
||||
- iterations are 1 through 10, inclusive;
|
||||
- parallelism is 1 through 16, inclusive; and
|
||||
- memory in KiB is at least `parallelism * 8`.
|
||||
|
||||
Given a 32-octet registration nonce, password octets are converted to a
|
||||
deterministic ML-KEM-768 private key as follows:
|
||||
|
||||
```text
|
||||
argonInput = FRAMES(
|
||||
ASCII("esiur/ppap-mlkem768-v1/password-seed"),
|
||||
UTF8(domain),
|
||||
UTF8(identity),
|
||||
passwordBytes,
|
||||
registrationNonce)
|
||||
|
||||
seed = Argon2id(
|
||||
password = argonInput,
|
||||
salt = registrationNonce,
|
||||
version/memory/iterations/parallelism = registration profile,
|
||||
outputLength = 64)
|
||||
|
||||
d = seed[0..32)
|
||||
z = seed[32..64)
|
||||
(publicKey, privateKey) = ML-KEM-768.KeyGen_internal(d, z)
|
||||
```
|
||||
|
||||
`KeyGen_internal` is the deterministic internal key-generation operation from
|
||||
FIPS 203; the public and private values use the standard ML-KEM-768 encapsulation
|
||||
and decapsulation-key encodings. This is the cross-language meaning of the .NET
|
||||
implementation's `MLKemPrivateKeyParameters.FromSeed(seed)`. The text-password
|
||||
API normalizes the password to NFC and then UTF-8 encodes it. The byte-password
|
||||
API uses the supplied octets unchanged. Provisioning and authentication
|
||||
implementations MUST make the same choice.
|
||||
|
||||
A static identity instead uses a provisioned ML-KEM-768 key pair and has neither
|
||||
a registration nonce nor a KDF profile. The `CreateStatic` API generates a new
|
||||
pair from a cryptographically random seed; the `FromStaticKey` API may import an
|
||||
existing validated 2400-octet private-key encoding.
|
||||
|
||||
### 4.2 Canonical registration descriptor
|
||||
|
||||
```text
|
||||
U8(kind)
|
||||
|| U64(version)
|
||||
|| F(nonce)
|
||||
|| U8(hasProfile)
|
||||
|| profile-if-present
|
||||
|
||||
profile-if-present =
|
||||
U32(argonVersion)
|
||||
|| U32(memoryKiB)
|
||||
|| U32(iterations)
|
||||
|| U32(parallelism)
|
||||
```
|
||||
|
||||
The version MUST be at least one. A password-derived descriptor has a 32-octet
|
||||
nonce, `hasProfile = 01`, and the 16-octet profile, for a canonical total of 62
|
||||
octets. A static descriptor has an empty nonce and `hasProfile = 00`, for a
|
||||
canonical total of 14 octets. Other marker values, kinds, combinations, and
|
||||
trailing data MUST be rejected. The generic descriptor decoder bounds input to
|
||||
10 through 128 octets.
|
||||
|
||||
### 4.3 Protected handshake descriptor
|
||||
|
||||
Descriptors in `InitiatorProof` and `ResponderProof` are protected independently
|
||||
of the later EP transport encryption. The plaintext is:
|
||||
|
||||
```text
|
||||
F(canonicalDescriptor) || zeroPadding
|
||||
```
|
||||
|
||||
It is exactly 128 octets. The descriptor length MUST be 1 through 124, and every
|
||||
padding octet MUST be zero. Define the role-specific values:
|
||||
|
||||
| Described registration | `role` | `roleLabel` | Containing type | `binding` |
|
||||
|---|---:|---|---:|---|
|
||||
| Initiator | `01` | initiator descriptor role label | `04` ResponderProof | `01 01 04` |
|
||||
| Responder | `02` | responder descriptor role label | `03` InitiatorProof | `01 02 03` |
|
||||
|
||||
Here the first binding octet is the PPAP wire version. Let `D = UTF8(domain)`:
|
||||
|
||||
```text
|
||||
salt = H(FRAMES(descriptor-salt-label, roleLabel, D, binding))
|
||||
key = HKDF(ephemeralSecret, salt,
|
||||
FRAMES(descriptor-key-label, roleLabel, D, binding), 32)
|
||||
nonce = HKDF(ephemeralSecret, salt,
|
||||
FRAMES(descriptor-nonce-label, roleLabel, D, binding), 12)
|
||||
aad = FRAMES(descriptor-aad-label, roleLabel, D, binding)
|
||||
```
|
||||
|
||||
The protected descriptor is AES-256-GCM encryption of the 128-octet plaintext,
|
||||
with `key`, `nonce`, and `aad`, serialized as 128 octets of ciphertext followed
|
||||
by the 16-octet tag. The derived nonce is not sent. An implementation MUST
|
||||
encrypt at most one descriptor for each role under one ephemeral secret. In
|
||||
particular, the responder MUST reuse the same protected initiator descriptor
|
||||
when constructing `ResponderProofCore` and the transmitted `ResponderProof`.
|
||||
|
||||
## 5. Authentication messages
|
||||
|
||||
Let `ephemeralPublicKey` be the initiator's fresh ML-KEM-768 public key; `mI`
|
||||
and `mR` are fresh 32-octet masks selected by initiator and responder.
|
||||
|
||||
```text
|
||||
ClientHello (01) payload =
|
||||
U8(mode)
|
||||
|| F(UTF8(domain))
|
||||
|| F(ephemeralPublicKey[1184])
|
||||
|| F(mI[32])
|
||||
|
||||
ServerHello (02) payload =
|
||||
F(ephemeralCiphertext[1088])
|
||||
|| F(mR[32])
|
||||
|| F(maskedResponderIdentity[32] or empty)
|
||||
|
||||
InitiatorProof (03) payload =
|
||||
F(maskedInitiatorIdentity[32] or empty)
|
||||
|| F(responderIdentityCiphertext[1088] or empty)
|
||||
|| F(protectedResponderDescriptor[144] or empty)
|
||||
|
||||
ResponderProof (04) payload =
|
||||
F(initiatorIdentityCiphertext[1088] or empty)
|
||||
|| F(protectedInitiatorDescriptor[144] or empty)
|
||||
|| F(responderFinished[32])
|
||||
|
||||
InitiatorFinished (05) payload =
|
||||
F(initiatorFinished[32])
|
||||
```
|
||||
|
||||
Field presence is exact:
|
||||
|
||||
| Field | `InitializerIdentity` | `ResponderIdentity` | `DualIdentity` |
|
||||
|---|:---:|:---:|:---:|
|
||||
| `maskedResponderIdentity` | empty | 32 | 32 |
|
||||
| `maskedInitiatorIdentity` | 32 | empty | 32 |
|
||||
| responder identity ciphertext + descriptor | empty | present | present |
|
||||
| initiator identity ciphertext + descriptor | present | empty | present |
|
||||
|
||||
An absent field is encoded as `F(empty)`, not omitted.
|
||||
|
||||
### 5.1 KEM and masked identities
|
||||
|
||||
The responder encapsulates to `ephemeralPublicKey`; both endpoints call the
|
||||
resulting 32-octet value `ephemeralSecret`. For each authenticated identity, the
|
||||
endpoint holding that identity's verifier registration encapsulates to its
|
||||
registered public key. The identity subject decapsulates using its static or
|
||||
password-derived private key. The resulting values are
|
||||
`initiatorIdentitySecret` and `responderIdentitySecret`; the secret for an
|
||||
unauthenticated role is empty in the key schedule.
|
||||
|
||||
An identity is masked as:
|
||||
|
||||
```text
|
||||
maskedIdentity = MAC(
|
||||
ephemeralSecret,
|
||||
FRAMES(masked-identity-label, UTF8(domain), UTF8(identity), mask))
|
||||
```
|
||||
|
||||
The responder identity uses `mI`; the initiator identity uses `mR`. A verifier
|
||||
store resolves the result only within the normalized domain. It MUST compare
|
||||
candidates in constant time, reject no match or more than one match, and MUST
|
||||
NOT persist the per-handshake `ephemeralSecret` or masked value as a stable
|
||||
lookup token.
|
||||
|
||||
This masking provides identity confidentiality against a passive observer of
|
||||
the clear authentication exchange. It does not provide anonymity from an active
|
||||
peer: a peer participating in the ephemeral KEM knows `ephemeralSecret` and can
|
||||
test identities it is otherwise able to enumerate.
|
||||
|
||||
### 5.2 Authentication context, transcript, and keys
|
||||
|
||||
The authentication context is:
|
||||
|
||||
```text
|
||||
FRAMES(
|
||||
ASCII("ppap-mlkem768-v1"),
|
||||
U8(mode),
|
||||
UTF8(domain),
|
||||
UTF8(initiatorIdentity) or empty,
|
||||
UTF8(responderIdentity) or empty,
|
||||
U8(initiatorKind or 0) || U8(responderKind or 0))
|
||||
```
|
||||
|
||||
`ResponderProofCore` is a complete PPAP envelope of type `04` whose payload is
|
||||
only:
|
||||
|
||||
```text
|
||||
F(initiatorIdentityCiphertext or empty)
|
||||
|| F(protectedInitiatorDescriptor or empty)
|
||||
```
|
||||
|
||||
It omits the Finished field entirely; it does not append `F(empty)`. Its PPAP
|
||||
payload length therefore differs from the transmitted `ResponderProof`.
|
||||
|
||||
```text
|
||||
transcriptHash = H(FRAMES(
|
||||
transcript-label,
|
||||
authenticationContext,
|
||||
full ClientHello envelope,
|
||||
full ServerHello envelope,
|
||||
full InitiatorProof envelope,
|
||||
ResponderProofCore envelope))
|
||||
|
||||
ikm = FRAMES(
|
||||
key-schedule-label,
|
||||
ephemeralSecret,
|
||||
initiatorIdentitySecret or empty,
|
||||
responderIdentitySecret or empty)
|
||||
|
||||
sessionKey = HKDF(
|
||||
ikm, transcriptHash,
|
||||
FRAMES(session-key-label, authenticationContext), 32)
|
||||
|
||||
initiatorFinishedKey = HKDF(
|
||||
ikm, transcriptHash,
|
||||
FRAMES(initiator-finished-key-label, authenticationContext), 32)
|
||||
|
||||
responderFinishedKey = HKDF(
|
||||
ikm, transcriptHash,
|
||||
FRAMES(responder-finished-key-label, authenticationContext), 32)
|
||||
|
||||
initiatorFinished = MAC(
|
||||
initiatorFinishedKey,
|
||||
FRAMES(initiator-finished-label, transcriptHash))
|
||||
|
||||
responderFinished = MAC(
|
||||
responderFinishedKey,
|
||||
FRAMES(responder-finished-label, transcriptHash))
|
||||
```
|
||||
|
||||
Finished values MUST be compared in constant time. A successfully returned
|
||||
ML-KEM decapsulation alone MUST NOT be treated as authentication; the Finished
|
||||
exchange confirms possession and the complete transcript.
|
||||
|
||||
### 5.3 Authentication state sequence
|
||||
|
||||
All three supported modes use the same ordered state sequence; only the field
|
||||
presence from the preceding table differs.
|
||||
|
||||
| Step | Sender | PPAP message | EP carriage |
|
||||
|---:|---|---|---|
|
||||
| 1 | Initiator | ClientHello | `Initialize`, `SessionHeaders.AuthenticationData` |
|
||||
| 2 | Responder | ServerHello | `ProceedToHandshake`, `SessionHeaders.AuthenticationData` |
|
||||
| 3 | Initiator | InitiatorProof | `Handshake` action |
|
||||
| 4 | Responder | ResponderProof | `Handshake` action |
|
||||
| 5 | Initiator | InitiatorFinished | `FinalHandshake` action |
|
||||
| 6 | Responder | no PPAP message | encrypted `Established` event |
|
||||
|
||||
The initiator validates the responder Finished before sending step 5. The
|
||||
responder validates the initiator Finished before step 6. The session key,
|
||||
local identity, and remote identity become handshake results only on successful
|
||||
validation. PPAP still is not application-ready at this point: the protected
|
||||
post-authentication phase in section 7 is mandatory.
|
||||
|
||||
## 6. Rotation messages and proof
|
||||
|
||||
The following are ordinary PPAP envelopes using the type codes in section 3.
|
||||
All role fields MUST be initiator `01` or responder `02`.
|
||||
|
||||
```text
|
||||
RotationStart payload =
|
||||
U8(role)
|
||||
|
||||
RotationOffer payload =
|
||||
U8(role)
|
||||
|| F(UTF8(identity))
|
||||
|| U64(expectedVersion)
|
||||
|| F(newPublicKey[1184])
|
||||
|| F(newPasswordDescriptor[62])
|
||||
|
||||
RotationChallenge payload =
|
||||
U8(role)
|
||||
|| F(ciphertext[1088])
|
||||
|
||||
RotationProof payload =
|
||||
U8(role)
|
||||
|| F(proof[32])
|
||||
|
||||
RotationCommit payload =
|
||||
U8(role)
|
||||
|| U64(committedVersion)
|
||||
|
||||
RotationCommitAck payload =
|
||||
U8(role)
|
||||
|| U64(committedVersion)
|
||||
|
||||
RotationDone payload = empty
|
||||
```
|
||||
|
||||
For an offer, `expectedVersion` MUST be in `1..2^63-2`. The embedded descriptor
|
||||
MUST be password-derived and its version MUST equal `expectedVersion + 1`.
|
||||
Commit and acknowledgement versions MUST be at least two. The descriptor in an
|
||||
offer is not protected by the descriptor AEAD from section 4.3; the complete
|
||||
rotation exchange is instead REQUIRED to be inside the negotiated encrypted EP
|
||||
transport.
|
||||
|
||||
The subject derives a new password key using a fresh 32-octet nonce, the same
|
||||
identity/domain, and the unchanged KDF profile. The verifier encapsulates to the
|
||||
offered public key, producing `challengeSecret` and the challenge ciphertext.
|
||||
Using the exact, complete PPAP envelope octets as received or sent:
|
||||
|
||||
```text
|
||||
rotationContext = H(FRAMES(
|
||||
rotation-proof-label,
|
||||
sessionKey,
|
||||
full RotationOffer envelope,
|
||||
full RotationChallenge envelope))
|
||||
|
||||
proof = MAC(challengeSecret, rotationContext)
|
||||
```
|
||||
|
||||
The verifier MUST compare the proof in constant time. ML-KEM decapsulation does
|
||||
not by itself validate a challenge.
|
||||
|
||||
### 6.1 Atomic registration commit
|
||||
|
||||
After proof verification, the verifier MUST perform one linearizable atomic
|
||||
compare-and-swap keyed by the normalized domain and identity. A separate read
|
||||
followed by an unconditional write is not conforming. The operation MUST return
|
||||
failure without mutation unless all of these are true:
|
||||
|
||||
- the current record exists and its version equals `expectedVersion`;
|
||||
- the replacement identity is exactly the same normalized identity under
|
||||
ordinal comparison;
|
||||
- the replacement version is exactly `expectedVersion + 1`;
|
||||
- both records are password-derived;
|
||||
- the Argon2 version, memory, iterations, and parallelism are unchanged;
|
||||
- the new nonce is not reused; and
|
||||
- the new encapsulation public key is not reused.
|
||||
|
||||
Material equality checks SHOULD be constant-time. At minimum, equality with the
|
||||
current nonce and key MUST be rejected; a durable store that retains historical
|
||||
material MUST also reject known historical reuse. A database implementation
|
||||
MUST use a conditional update or transaction that provides the same CAS
|
||||
semantics.
|
||||
|
||||
The verifier MUST NOT send `RotationCommit` unless the CAS succeeds. The subject
|
||||
MUST retain its pending registration until it receives a commit for the exact
|
||||
role and pending version, and only then adopt it. A CAS failure, version mismatch,
|
||||
or concurrent update terminates the protocol rather than being retried in the
|
||||
same session.
|
||||
|
||||
Consequently, when multiple successfully authenticated sessions concurrently
|
||||
offer replacements for the same registration version, exactly one CAS can win.
|
||||
The other sessions fail rotation and MUST begin a fresh authentication attempt
|
||||
against the newly committed version before retrying.
|
||||
|
||||
The two role updates in `DualIdentity` mode are sequential CAS operations, not
|
||||
one transaction spanning both records. A committed initiator-role update is not
|
||||
rolled back if the later responder-role rotation fails; a retry starts from the
|
||||
versions then present in the store.
|
||||
|
||||
## 7. Required post-authentication state sequence
|
||||
|
||||
A role requires a real rotation exactly when it was authenticated and its
|
||||
registration kind is password-derived. Static identities are never rotated.
|
||||
The initiator role is processed before the responder role. `I` and `R` below are
|
||||
the connection initiator and responder; a parenthesized role is the registration
|
||||
being rotated.
|
||||
|
||||
```text
|
||||
No password-derived role:
|
||||
I -> R RotationDone
|
||||
|
||||
Initiator role only:
|
||||
I -> R RotationOffer(I)
|
||||
R -> I RotationChallenge(I)
|
||||
I -> R RotationProof(I)
|
||||
R -> I RotationCommit(I) # after responder-side CAS
|
||||
I -> R RotationDone
|
||||
|
||||
Responder role only:
|
||||
I -> R RotationStart(R)
|
||||
R -> I RotationOffer(R)
|
||||
I -> R RotationChallenge(R)
|
||||
R -> I RotationProof(R)
|
||||
I -> R RotationCommit(R) # after initiator-side CAS
|
||||
R -> I RotationCommitAck(R)
|
||||
I -> R RotationDone
|
||||
|
||||
Both roles:
|
||||
I -> R RotationOffer(I)
|
||||
R -> I RotationChallenge(I)
|
||||
I -> R RotationProof(I)
|
||||
R -> I RotationCommit(I) # after responder-side CAS
|
||||
I -> R RotationStart(R)
|
||||
R -> I RotationOffer(R)
|
||||
I -> R RotationChallenge(R)
|
||||
R -> I RotationProof(R)
|
||||
I -> R RotationCommit(R) # after initiator-side CAS
|
||||
R -> I RotationCommitAck(R)
|
||||
I -> R RotationDone
|
||||
```
|
||||
|
||||
After accepting `RotationDone`, the responder queues the encrypted EP
|
||||
`KeyRotationEstablished` event first, then sets `Session.Authenticated` and
|
||||
publishes local readiness. Even when no password-derived role exists, the
|
||||
encrypted `RotationDone`/`KeyRotationEstablished` exchange is mandatory. The
|
||||
initiator MUST NOT publish readiness before receiving that event.
|
||||
|
||||
Messages for another role, duplicates, reordered messages, unexpected
|
||||
acknowledgements, or messages received after completion MUST fail the exchange.
|
||||
|
||||
## 8. EP transport binding and encryption requirement
|
||||
|
||||
PPAP is registered only under the exact protocol string
|
||||
`ppap-mlkem768-v1`; implementations MUST NOT silently accept aliases.
|
||||
|
||||
The EP method codes used by this suite are:
|
||||
|
||||
| Logical EP method | Code |
|
||||
|---|---:|
|
||||
| `Handshake` action | `80` |
|
||||
| `FinalHandshake` action | `81` |
|
||||
| `KeyRotation` action | `82` |
|
||||
| `ProceedToHandshake` acknowledgement | `44` |
|
||||
| `Established` event | `C0` |
|
||||
| `ErrorTerminate` event | `C1` |
|
||||
| `ErrorMustEncrypt` event | `C2` |
|
||||
| `KeyRotationEstablished` event | `C9` |
|
||||
|
||||
The `Initialize` method octet, when its required headers TDU is present, is:
|
||||
|
||||
```text
|
||||
20 | (authenticationMode << 2) | encryptionMode
|
||||
```
|
||||
|
||||
The encryption-mode codes are `00` none, `01` session key, and `02` session key
|
||||
plus addresses. PPAP MUST use `01` or `02`; `00` MUST be rejected because PPAP
|
||||
always requires the protected post-authentication phase.
|
||||
|
||||
`Initialize` and `ProceedToHandshake` carry the PPAP envelope as the byte-array
|
||||
value of indexed `SessionHeaders.AuthenticationData` (index 15), using the
|
||||
standard EP indexed-structure codec. `SessionHeaders.AuthenticationProtocol`
|
||||
(index 14) is the exact PPAP protocol name. The domain header (index 1) carries
|
||||
the connection's configured domain; the PPAP handler normalizes that value to
|
||||
NFC for its context. ClientHello carries the normalized domain, and the
|
||||
responder compares the two normalized values.
|
||||
|
||||
For subsequent messages, a non-null PPAP envelope is encoded as an EP RawData
|
||||
TDU. Since a PPAP envelope is 10 through 8192 octets, its exact direct carriage
|
||||
is one of:
|
||||
|
||||
```text
|
||||
U8(logicalMethod | 20) || 48 || U8(L) || ppapEnvelope # 10 <= L <= 255
|
||||
U8(logicalMethod | 20) || 50 || U16(L) || ppapEnvelope # 256 <= L <= 8192
|
||||
```
|
||||
|
||||
Here `20`, `48`, and `50` are hexadecimal octets and `U16` is unsigned
|
||||
big-endian. Thus data-bearing Handshake, FinalHandshake, and KeyRotation method
|
||||
octets are respectively `A0`, `A1`, and `A2`. A method without data is its lone
|
||||
logical method octet; PPAP's final `KeyRotationEstablished` is therefore `C9`
|
||||
before record protection.
|
||||
|
||||
The authentication messages through `InitiatorFinished` are sent before full
|
||||
bidirectional transport protection. After verifying `InitiatorFinished`, the
|
||||
responder enables encryption and sends `Established` as its first protected EP
|
||||
message. The initiator must already accept protected inbound data; on receiving
|
||||
that protected event it enables protected outbound data and begins rotation.
|
||||
|
||||
Every `KeyRotation` action, `RotationDone`, and `KeyRotationEstablished` event
|
||||
MUST be encrypted. EP encrypted records are:
|
||||
|
||||
```text
|
||||
U32(len(protectedPayload)) || protectedPayload
|
||||
```
|
||||
|
||||
where `protectedPayload` is produced by the separately negotiated EP encryption
|
||||
provider over the complete method/TDU plaintext. Its provider-specific format is
|
||||
outside this PPAP suite. Receipt of rotation data while encryption is inactive
|
||||
MUST terminate authentication. `Session.Authenticated` and application readiness
|
||||
MUST remain false until rotation succeeds and readiness is published.
|
||||
|
||||
## 9. Interoperability and security invariants
|
||||
|
||||
A conforming implementation MUST fail closed on any of the following:
|
||||
|
||||
- wrong magic, wire version, expected message type, role, state, mode, or domain;
|
||||
- a total, payload, field, key, ciphertext, mask, tag, proof, or Finished length
|
||||
outside the exact bounds above;
|
||||
- a declared length mismatch, integer outside the accepted range, truncated
|
||||
input, or trailing input;
|
||||
- an optional field whose presence does not exactly match the authentication
|
||||
mode;
|
||||
- invalid UTF-8, identity kind, KDF profile, descriptor marker, descriptor
|
||||
combination, AEAD tag, or nonzero descriptor padding;
|
||||
- an unresolved, ambiguous, or unexpected masked identity;
|
||||
- a registration kind/profile/version/identity mismatch;
|
||||
- a Finished, rotation proof, commit, acknowledgement, or CAS failure; or
|
||||
- a rotation message outside active encrypted transport.
|
||||
|
||||
Proofs, Finished values, masked-identity candidates, and secret-derived material
|
||||
comparisons MUST use constant-time equality. Implementations SHOULD erase
|
||||
passwords, private keys, KEM secrets, Finished keys, derived AEAD material, and
|
||||
pending rotation secrets as soon as their state no longer needs them. A handler
|
||||
instance is single-connection state and MUST NOT be reused.
|
||||
|
||||
The clear authentication exchange carries only per-handshake masked identities;
|
||||
it does not carry clear identity strings. A `RotationOffer` does contain its
|
||||
normalized identity and descriptor, which is why rotation is forbidden outside
|
||||
the encrypted EP transport. The deterministic descriptor nonce is safe only
|
||||
under the one-descriptor-per-role-per-ephemeral-secret rule.
|
||||
|
||||
ML-KEM's implicit rejection behavior MUST remain indistinguishable at the
|
||||
decapsulation API boundary. Only the transcript-bound Finished exchange or the
|
||||
session-bound rotation proof establishes success. Expensive Argon2 work SHOULD
|
||||
also be concurrency-limited so unauthenticated peers cannot exhaust local
|
||||
memory.
|
||||
|
||||
### 9.1 Current local abuse controls (not wire protocol)
|
||||
|
||||
These controls do not change any PPAP octet and are not cross-language
|
||||
interoperability requirements, but deployments need equivalent resource policy:
|
||||
|
||||
- The shared .NET password-derivation limiter allows
|
||||
`max(1, min(processorCount, 4))` concurrent Argon2 operations. When more than
|
||||
one processor is available, one slot is reserved for post-authentication
|
||||
rotation. A saturated limiter rejects a derivation instead of queueing it.
|
||||
- The default Warehouse connection-admission policy permits 120 connection
|
||||
attempts per IP address in a one-minute window. Both values are
|
||||
configurable, and setting the attempt limit to zero disables it.
|
||||
- `InMemoryPpapRegistrationStore.ResolveMasked` deliberately scans the entire
|
||||
store, evaluating candidates in the normalized domain, and is therefore
|
||||
`O(N)` in total registrations per masked lookup. It is intended for tests and
|
||||
small deployments. A large store needs a bounded or optimized lookup strategy
|
||||
that still avoids persistent/stable masked tokens, uses constant-time
|
||||
candidate comparison, and rejects ambiguity.
|
||||
|
||||
## 10. Implementation correspondence
|
||||
|
||||
The constants and encodings in this document are derived from these source
|
||||
files:
|
||||
|
||||
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapCryptography.cs`
|
||||
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapWire.cs`
|
||||
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapRotationWire.cs`
|
||||
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapModels.cs`
|
||||
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapAuthenticationHandler.cs`
|
||||
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapAuthenticationHandler.Rotation.cs`
|
||||
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapAuthenticationProvider.cs`
|
||||
- `Libraries/Esiur/Security/Authority/Providers/Ppap/PpapPasswordDerivationLimiter.cs`
|
||||
- `Libraries/Esiur/Security/Authority/AuthenticationMode.cs`
|
||||
- `Libraries/Esiur/Security/Authority/Session.cs`
|
||||
- `Libraries/Esiur/Security/Cryptography/EncryptionMode.cs`
|
||||
- `Libraries/Esiur/Protocol/EpConnection.cs`
|
||||
- `Libraries/Esiur/Protocol/EpConnectionProtocol.cs`
|
||||
- `Libraries/Esiur/Protocol/EpServer.cs`
|
||||
- `Libraries/Esiur/Net/Packets/EpAuthPacket.cs`
|
||||
- `Libraries/Esiur/Net/Packets/EpAuthPacketMethod.cs`
|
||||
- `Libraries/Esiur/Data/Tdu.cs`
|
||||
- `Libraries/Esiur/Data/TduIdentifier.cs`
|
||||
- `Libraries/Esiur/Resource/WarehouseConfiguration.cs`
|
||||
@@ -27,6 +27,7 @@ using Esiur.Data;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
using Esiur.Security.Cryptography;
|
||||
using Esiur.Security.Management;
|
||||
using Esiur.Security.RateLimiting;
|
||||
@@ -120,7 +121,10 @@ internal static class Program
|
||||
var server = await warehouse.Put("sys/server", new EpServer
|
||||
{
|
||||
Port = port,
|
||||
AllowedAuthenticationProviders = new[] { "hash" },
|
||||
AllowedAuthenticationProviders = new[]
|
||||
{
|
||||
PasswordAuthenticationProvider.ProtocolName,
|
||||
},
|
||||
AllowedEncryptionProviders = new[] { AesEncryptionProvider.Name },
|
||||
RequireEncryption = true,
|
||||
});
|
||||
@@ -185,7 +189,7 @@ internal static class Program
|
||||
AuthenticationMode = AuthenticationMode.InitializerIdentity,
|
||||
AutoReconnect = false,
|
||||
Identity = "tester",
|
||||
AuthenticationProtocol = "hash",
|
||||
AuthenticationProtocol = PasswordAuthenticationProvider.ProtocolName,
|
||||
Domain = "test",
|
||||
EncryptionMode = EncryptionMode.EncryptWithSessionKey,
|
||||
EncryptionProviders = new[] { AesEncryptionProvider.Name },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
using Esiur.Security.Cryptography;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
@@ -26,7 +27,7 @@ public class AesEncryptionProviderTests
|
||||
var record = initiator.Encrypt("Esiur AES-GCM vector"u8.ToArray());
|
||||
|
||||
Assert.Equal(
|
||||
"0000000000000000BF5D9D7DA3D6D5232578F966450E5B96B89172D4F186A5C45299C42D8ABB732A07B1E092",
|
||||
"0000000000000000D9538F565DFB97D51FE9AA53316A1FE2D5EEF3361A8958E4439C0BE21A6FC840A3DF42F9",
|
||||
Convert.ToHexString(record));
|
||||
}
|
||||
|
||||
@@ -145,8 +146,8 @@ public class AesEncryptionProviderTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AuthenticationMode.DualIdentity, "hash")]
|
||||
[InlineData(AuthenticationMode.InitializerIdentity, "hash-v2")]
|
||||
[InlineData(AuthenticationMode.DualIdentity, "password-sha3-v1")]
|
||||
[InlineData(AuthenticationMode.InitializerIdentity, "password-sha3-v2")]
|
||||
public void AuthenticationNegotiation_IsBoundIntoKeyDerivation(
|
||||
AuthenticationMode responderMode,
|
||||
string responderProtocol)
|
||||
@@ -242,7 +243,7 @@ public class AesEncryptionProviderTests
|
||||
string protocol = AesEncryptionProvider.Name,
|
||||
string[]? offeredProtocols = null,
|
||||
AuthenticationMode authenticationMode = AuthenticationMode.InitializerIdentity,
|
||||
string authenticationProtocol = "hash",
|
||||
string authenticationProtocol = PasswordAuthenticationProvider.ProtocolName,
|
||||
string domain = "example.test")
|
||||
=> (AesGcmSymetricCipher)_provider.CreateCipher(new EncryptionContext
|
||||
{
|
||||
|
||||
@@ -82,6 +82,22 @@ public class AuthHandshakeTests
|
||||
|
||||
// ---- happy path ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ProviderAndHandler_UseVersionedPasswordProtocolName()
|
||||
{
|
||||
var provider = new PasswordAuthenticationProvider();
|
||||
var handler = provider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = AuthenticationDirection.Initiator,
|
||||
Mode = AuthenticationMode.InitializerIdentity,
|
||||
Domain = "domain",
|
||||
HostName = "host",
|
||||
});
|
||||
|
||||
Assert.Equal("password-sha3-v1", provider.DefaultName);
|
||||
Assert.Equal(provider.DefaultName, handler.Protocol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializerIdentity_Handshake_Derives_Matching_SessionKeys()
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using Esiur.Data;
|
||||
using Esiur.Net.Packets;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
@@ -93,7 +94,7 @@ public class IndexedStructureTests
|
||||
SupportedCiphers = new[] { "aes-gcm" },
|
||||
CipherType = "aes-gcm",
|
||||
IPAddress = new byte[] { 127, 0, 0, 1 },
|
||||
AuthenticationProtocol = "hash",
|
||||
AuthenticationProtocol = PasswordAuthenticationProvider.ProtocolName,
|
||||
AuthenticationData = new byte[] { 1, 2, 3 },
|
||||
CipherNonce = Enumerable.Range(0, 32).Select(x => (byte)x).ToArray(),
|
||||
};
|
||||
@@ -116,7 +117,8 @@ public class IndexedStructureTests
|
||||
|
||||
Assert.Equal(legacyBytes, bytes);
|
||||
Assert.Equal("example.test", map[(byte)EpAuthPacketHeader.Domain]);
|
||||
Assert.Equal("hash", map[(byte)EpAuthPacketHeader.AuthenticationProtocol]);
|
||||
Assert.Equal(PasswordAuthenticationProvider.ProtocolName,
|
||||
map[(byte)EpAuthPacketHeader.AuthenticationProtocol]);
|
||||
Assert.False(map.ContainsKey((byte)EpAuthPacketHeader.ErrorMessage));
|
||||
Assert.Equal(source.Domain, parsed.Domain);
|
||||
Assert.Equal(source.IPAddress, parsed.IPAddress);
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
using System.Threading;
|
||||
using Esiur.Core;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Cryptography;
|
||||
using Esiur.Stores;
|
||||
|
||||
namespace Esiur.Tests.Unit.Integration;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class AuthenticationKeyRotationIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RequiredRotation_CompletesBeforeEncryptedConnectionBecomesReady()
|
||||
{
|
||||
var serverProvider = new TestRotatingAuthenticationProvider();
|
||||
var clientProvider = new TestRotatingAuthenticationProvider();
|
||||
|
||||
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
|
||||
serverProvider,
|
||||
clientProvider,
|
||||
encrypted: true,
|
||||
populate: async warehouse =>
|
||||
await warehouse.Put("sys/rotated", new Node { Id = 73 }))
|
||||
.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
var serverConnection = Assert.Single(cluster.Server.Connections);
|
||||
|
||||
Assert.True(clientProvider.RotationStarted);
|
||||
Assert.True(serverProvider.RotationCommitted);
|
||||
Assert.True(cluster.Connection.Session.Authenticated);
|
||||
Assert.True(serverConnection.Session.Authenticated);
|
||||
Assert.True(cluster.Connection.IsEncrypted);
|
||||
Assert.True(serverConnection.IsEncrypted);
|
||||
|
||||
var resource = await Task.Run(async () =>
|
||||
await cluster.Connection.Get("sys/rotated"))
|
||||
.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
Assert.NotNull(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequiredRotation_RejectsPlaintextTransport()
|
||||
{
|
||||
var serverProvider = new TestRotatingAuthenticationProvider();
|
||||
var clientProvider = new TestRotatingAuthenticationProvider();
|
||||
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await CustomAuthenticationIntegrationCluster.StartAsync(
|
||||
serverProvider,
|
||||
clientProvider,
|
||||
encrypted: false)
|
||||
.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
|
||||
Assert.IsNotType<TimeoutException>(exception);
|
||||
Assert.False(clientProvider.RotationStarted);
|
||||
Assert.False(serverProvider.RotationCommitted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidRotationProof_FailsClosedBeforeConnectionBecomesReady()
|
||||
{
|
||||
var serverProvider = new TestRotatingAuthenticationProvider();
|
||||
var clientProvider = new TestRotatingAuthenticationProvider(corruptProof: true);
|
||||
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await CustomAuthenticationIntegrationCluster.StartAsync(
|
||||
serverProvider,
|
||||
clientProvider,
|
||||
encrypted: true)
|
||||
.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
|
||||
Assert.IsNotType<TimeoutException>(exception);
|
||||
Assert.True(clientProvider.RotationStarted);
|
||||
Assert.True(serverProvider.RotationRejected);
|
||||
Assert.False(serverProvider.RotationCommitted);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestRotatingAuthenticationProvider : IAuthenticationProvider
|
||||
{
|
||||
readonly bool _corruptProof;
|
||||
|
||||
public TestRotatingAuthenticationProvider(bool corruptProof = false)
|
||||
=> _corruptProof = corruptProof;
|
||||
|
||||
public string DefaultName => "rotating-test";
|
||||
public bool RotationStarted { get; internal set; }
|
||||
public bool RotationCommitted { get; internal set; }
|
||||
public bool RotationRejected { get; internal set; }
|
||||
|
||||
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
|
||||
=> new TestRotatingAuthenticationHandler(this, context.Direction, _corruptProof);
|
||||
|
||||
public AsyncReply<bool> Login(Session session) => new(true);
|
||||
public AsyncReply<bool> Logout(Session session) => new(true);
|
||||
}
|
||||
|
||||
internal sealed class TestRotatingAuthenticationHandler :
|
||||
IAuthenticationHandler,
|
||||
IAuthenticationKeyRotationHandler
|
||||
{
|
||||
const string BeginToken = "rotation-begin";
|
||||
const string ChallengeToken = "rotation-challenge";
|
||||
const string ProofToken = "rotation-proof";
|
||||
const string InvalidProofToken = "rotation-proof-invalid";
|
||||
const string AcknowledgementToken = "rotation-ack";
|
||||
|
||||
static readonly byte[] SessionKey =
|
||||
Enumerable.Range(1, 64).Select(value => (byte)value).ToArray();
|
||||
|
||||
readonly TestRotatingAuthenticationProvider _provider;
|
||||
readonly AuthenticationDirection _direction;
|
||||
readonly bool _corruptProof;
|
||||
int _rotationStep;
|
||||
|
||||
public TestRotatingAuthenticationHandler(
|
||||
TestRotatingAuthenticationProvider provider,
|
||||
AuthenticationDirection direction,
|
||||
bool corruptProof)
|
||||
{
|
||||
_provider = provider;
|
||||
_direction = direction;
|
||||
_corruptProof = corruptProof;
|
||||
}
|
||||
|
||||
public IAuthenticationProvider Provider => _provider;
|
||||
public string Protocol => _provider.DefaultName;
|
||||
public bool RequiresKeyRotation => true;
|
||||
|
||||
public AuthenticationResult Process(object authData)
|
||||
=> new(
|
||||
AuthenticationRuling.Succeeded,
|
||||
authenticationData: Array.Empty<byte>(),
|
||||
localIdentity: _direction == AuthenticationDirection.Initiator ? "client" : "server",
|
||||
remoteIdentity: _direction == AuthenticationDirection.Initiator ? "server" : "client",
|
||||
sessionKey: (byte[])SessionKey.Clone());
|
||||
|
||||
public AuthenticationKeyRotationResult BeginKeyRotation()
|
||||
{
|
||||
if (_direction != AuthenticationDirection.Initiator
|
||||
|| Interlocked.CompareExchange(ref _rotationStep, 1, 0) != 0)
|
||||
{
|
||||
return Failed("Rotation was started in an invalid state.");
|
||||
}
|
||||
|
||||
_provider.RotationStarted = true;
|
||||
return InProgress(BeginToken);
|
||||
}
|
||||
|
||||
public AuthenticationKeyRotationResult ProcessKeyRotation(object data)
|
||||
{
|
||||
if (_direction == AuthenticationDirection.Initiator)
|
||||
{
|
||||
if (_rotationStep != 1 || !String.Equals(data as string, ChallengeToken, StringComparison.Ordinal))
|
||||
return Failed("The responder rotation challenge is invalid.");
|
||||
|
||||
_rotationStep = 2;
|
||||
return Succeeded(_corruptProof ? InvalidProofToken : ProofToken);
|
||||
}
|
||||
|
||||
if (_rotationStep == 0 && String.Equals(data as string, BeginToken, StringComparison.Ordinal))
|
||||
{
|
||||
_rotationStep = 1;
|
||||
return InProgress(ChallengeToken);
|
||||
}
|
||||
|
||||
if (_rotationStep == 1)
|
||||
{
|
||||
if (!String.Equals(data as string, ProofToken, StringComparison.Ordinal))
|
||||
{
|
||||
_provider.RotationRejected = true;
|
||||
return Failed("The initiator rotation proof is invalid.");
|
||||
}
|
||||
|
||||
_rotationStep = 2;
|
||||
_provider.RotationCommitted = true;
|
||||
return Succeeded(AcknowledgementToken);
|
||||
}
|
||||
|
||||
return Failed("The rotation message arrived out of sequence.");
|
||||
}
|
||||
|
||||
static AuthenticationKeyRotationResult InProgress(object data)
|
||||
=> new(AuthenticationKeyRotationRuling.InProgress, data);
|
||||
|
||||
static AuthenticationKeyRotationResult Succeeded(object data)
|
||||
=> new(AuthenticationKeyRotationRuling.Succeeded, data);
|
||||
|
||||
static AuthenticationKeyRotationResult Failed(string error)
|
||||
=> new(AuthenticationKeyRotationRuling.Failed, error: error);
|
||||
}
|
||||
|
||||
internal sealed class CustomAuthenticationIntegrationCluster : IAsyncDisposable
|
||||
{
|
||||
static int _portCounter = 16400;
|
||||
|
||||
public Warehouse ServerWarehouse { get; }
|
||||
public Warehouse ClientWarehouse { get; }
|
||||
public EpServer Server { get; }
|
||||
public EpConnection Connection { get; private set; } = null!;
|
||||
|
||||
CustomAuthenticationIntegrationCluster(
|
||||
Warehouse serverWarehouse,
|
||||
Warehouse clientWarehouse,
|
||||
EpServer server)
|
||||
{
|
||||
ServerWarehouse = serverWarehouse;
|
||||
ClientWarehouse = clientWarehouse;
|
||||
Server = server;
|
||||
}
|
||||
|
||||
public static async Task<CustomAuthenticationIntegrationCluster> StartAsync(
|
||||
IAuthenticationProvider serverProvider,
|
||||
IAuthenticationProvider clientProvider,
|
||||
bool encrypted,
|
||||
Func<Warehouse, Task>? populate = null,
|
||||
AuthenticationMode authenticationMode = AuthenticationMode.InitializerIdentity,
|
||||
string? identity = "client",
|
||||
string? responderIdentity = null,
|
||||
string? domain = "test",
|
||||
Action<EpServer>? serverCreated = null)
|
||||
{
|
||||
var port = Interlocked.Increment(ref _portCounter);
|
||||
var serverWarehouse = new Warehouse();
|
||||
var clientWarehouse = new Warehouse();
|
||||
|
||||
serverWarehouse.RegisterAuthenticationProvider(serverProvider);
|
||||
clientWarehouse.RegisterAuthenticationProvider(clientProvider);
|
||||
|
||||
if (encrypted)
|
||||
{
|
||||
serverWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
|
||||
clientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
|
||||
}
|
||||
|
||||
await serverWarehouse.Put("sys", new MemoryStore());
|
||||
var server = await serverWarehouse.Put("sys/server", new EpServer
|
||||
{
|
||||
Port = (ushort)port,
|
||||
AllowedAuthenticationProviders = new[] { serverProvider.DefaultName },
|
||||
AllowedEncryptionProviders = encrypted
|
||||
? new[] { AesEncryptionProvider.Name }
|
||||
: Array.Empty<string>(),
|
||||
});
|
||||
serverCreated?.Invoke(server);
|
||||
|
||||
if (populate != null)
|
||||
await populate(serverWarehouse);
|
||||
|
||||
await serverWarehouse.Open();
|
||||
|
||||
var cluster = new CustomAuthenticationIntegrationCluster(
|
||||
serverWarehouse,
|
||||
clientWarehouse,
|
||||
server);
|
||||
|
||||
try
|
||||
{
|
||||
var context = new EpConnectionContext
|
||||
{
|
||||
AuthenticationMode = authenticationMode,
|
||||
AuthenticationProtocol = clientProvider.DefaultName,
|
||||
Identity = identity!,
|
||||
ResponderIdentity = responderIdentity!,
|
||||
EncryptionMode = encrypted
|
||||
? EncryptionMode.EncryptWithSessionKey
|
||||
: EncryptionMode.None,
|
||||
EncryptionProviders = new[] { AesEncryptionProvider.Name },
|
||||
};
|
||||
if (domain != null)
|
||||
context.Domain = domain;
|
||||
|
||||
cluster.Connection = await clientWarehouse.Get<EpConnection>(
|
||||
$"ep://localhost:{port}",
|
||||
context);
|
||||
|
||||
return cluster;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { server.Destroy(); } catch { }
|
||||
try { await clientWarehouse.Close(); } catch { }
|
||||
try { await serverWarehouse.Close(); } catch { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try { Connection.Destroy(); } catch { }
|
||||
try { Server.Destroy(); } catch { }
|
||||
try { await ClientWarehouse.Close(); } catch { }
|
||||
try { await ServerWarehouse.Close(); } catch { }
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Esiur.Core;
|
||||
@@ -11,7 +13,7 @@ using Esiur.Stores;
|
||||
|
||||
namespace Esiur.Tests.Unit.Integration;
|
||||
|
||||
// ---- hash auth providers (self-consistent: client password {1..5} || server salt {6..10}
|
||||
// ---- password auth providers (self-consistent: client password {1..5} || server salt {6..10}
|
||||
// == {1..10}, which is what the server stores the hash of) ------------------------------
|
||||
|
||||
internal class TestServerAuthProvider : PasswordAuthenticationProvider
|
||||
@@ -38,33 +40,53 @@ internal class TestClientAuthProvider : PasswordAuthenticationProvider
|
||||
internal sealed class OneStepAuthenticationProvider : IAuthenticationProvider
|
||||
{
|
||||
readonly byte _keyMarker;
|
||||
readonly bool _requiresKeyRotation;
|
||||
|
||||
public OneStepAuthenticationProvider(byte keyMarker = 0)
|
||||
=> _keyMarker = keyMarker;
|
||||
public OneStepAuthenticationProvider(byte keyMarker = 0, bool requiresKeyRotation = false)
|
||||
{
|
||||
_keyMarker = keyMarker;
|
||||
_requiresKeyRotation = requiresKeyRotation;
|
||||
}
|
||||
|
||||
public string DefaultName => "one-step";
|
||||
|
||||
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
|
||||
=> new OneStepAuthenticationHandler(this, _keyMarker);
|
||||
=> new OneStepAuthenticationHandler(
|
||||
this,
|
||||
_keyMarker,
|
||||
context.Direction,
|
||||
_requiresKeyRotation);
|
||||
|
||||
public AsyncReply<bool> Login(Session session) => new AsyncReply<bool>(true);
|
||||
public AsyncReply<bool> Logout(Session session) => new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
internal sealed class OneStepAuthenticationHandler : IAuthenticationHandler
|
||||
internal sealed class OneStepAuthenticationHandler :
|
||||
IAuthenticationHandler,
|
||||
IAuthenticationKeyRotationHandler
|
||||
{
|
||||
static readonly byte[] SharedKey = Enumerable.Range(1, 64).Select(x => (byte)x).ToArray();
|
||||
readonly OneStepAuthenticationProvider _provider;
|
||||
readonly byte _keyMarker;
|
||||
readonly AuthenticationDirection _direction;
|
||||
int _keyRotationStep;
|
||||
|
||||
public OneStepAuthenticationHandler(OneStepAuthenticationProvider provider, byte keyMarker)
|
||||
public OneStepAuthenticationHandler(
|
||||
OneStepAuthenticationProvider provider,
|
||||
byte keyMarker,
|
||||
AuthenticationDirection direction,
|
||||
bool requiresKeyRotation)
|
||||
{
|
||||
_provider = provider;
|
||||
_keyMarker = keyMarker;
|
||||
_direction = direction;
|
||||
RequiresKeyRotation = requiresKeyRotation;
|
||||
}
|
||||
|
||||
public IAuthenticationProvider Provider => _provider;
|
||||
public string Protocol => _provider.DefaultName;
|
||||
public bool RequiresKeyRotation { get; }
|
||||
public bool KeyRotationCompleted { get; private set; }
|
||||
|
||||
public AuthenticationResult Process(object authData)
|
||||
{
|
||||
@@ -77,6 +99,49 @@ internal sealed class OneStepAuthenticationHandler : IAuthenticationHandler
|
||||
"server",
|
||||
key);
|
||||
}
|
||||
|
||||
public AuthenticationKeyRotationResult BeginKeyRotation()
|
||||
{
|
||||
if (!RequiresKeyRotation || _direction != AuthenticationDirection.Initiator)
|
||||
return new(AuthenticationKeyRotationRuling.Failed, error: "Invalid key-rotation initiator.");
|
||||
|
||||
_keyRotationStep = 1;
|
||||
return new(AuthenticationKeyRotationRuling.InProgress, new byte[] { 0xA1 });
|
||||
}
|
||||
|
||||
public AuthenticationKeyRotationResult ProcessKeyRotation(object data)
|
||||
{
|
||||
if (!RequiresKeyRotation || data is not byte[] message || message.Length != 1)
|
||||
return new(AuthenticationKeyRotationRuling.Failed, error: "Invalid key-rotation data.");
|
||||
|
||||
if (_direction == AuthenticationDirection.Responder
|
||||
&& _keyRotationStep == 0
|
||||
&& message[0] == 0xA1)
|
||||
{
|
||||
_keyRotationStep = 1;
|
||||
return new(AuthenticationKeyRotationRuling.InProgress, new byte[] { 0xB2 });
|
||||
}
|
||||
|
||||
if (_direction == AuthenticationDirection.Initiator
|
||||
&& _keyRotationStep == 1
|
||||
&& message[0] == 0xB2)
|
||||
{
|
||||
_keyRotationStep = 2;
|
||||
KeyRotationCompleted = true;
|
||||
return new(AuthenticationKeyRotationRuling.Succeeded, new byte[] { 0xC3 });
|
||||
}
|
||||
|
||||
if (_direction == AuthenticationDirection.Responder
|
||||
&& _keyRotationStep == 1
|
||||
&& message[0] == 0xC3)
|
||||
{
|
||||
_keyRotationStep = 2;
|
||||
KeyRotationCompleted = true;
|
||||
return new(AuthenticationKeyRotationRuling.Succeeded);
|
||||
}
|
||||
|
||||
return new(AuthenticationKeyRotationRuling.Failed, error: "Invalid key-rotation sequence.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -88,6 +153,29 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
{
|
||||
static int _portCounter = 14400;
|
||||
|
||||
static int NextAvailablePort()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var candidate = Interlocked.Increment(ref _portCounter);
|
||||
using var probe = new Socket(
|
||||
AddressFamily.InterNetwork,
|
||||
SocketType.Stream,
|
||||
ProtocolType.Tcp);
|
||||
|
||||
try
|
||||
{
|
||||
probe.Bind(new IPEndPoint(IPAddress.Any, candidate));
|
||||
return candidate;
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// Tests share the host with other applications. Skip ports already
|
||||
// bound or reserved instead of making the suite environment-dependent.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Warehouse ServerWarehouse { get; }
|
||||
public Warehouse ClientWarehouse { get; }
|
||||
public EpServer Server { get; }
|
||||
@@ -115,15 +203,16 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
bool oneStepAuthentication = false,
|
||||
bool useWebSocket = false,
|
||||
bool mismatchedSessionKeys = false,
|
||||
bool requireKeyRotation = false,
|
||||
bool allowAuthentication = true,
|
||||
bool registerServerAuthenticationProvider = true)
|
||||
{
|
||||
var port = Interlocked.Increment(ref _portCounter);
|
||||
var port = NextAvailablePort();
|
||||
|
||||
var serverWh = new Warehouse();
|
||||
if (registerServerAuthenticationProvider)
|
||||
serverWh.RegisterAuthenticationProvider(oneStepAuthentication
|
||||
? new OneStepAuthenticationProvider()
|
||||
? new OneStepAuthenticationProvider(requiresKeyRotation: requireKeyRotation)
|
||||
: new TestServerAuthProvider());
|
||||
if (encrypted || requireEncryption)
|
||||
serverWh.RegisterEncryptionProvider(new AesEncryptionProvider());
|
||||
@@ -133,7 +222,12 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
{
|
||||
Port = (ushort)port,
|
||||
AllowedAuthenticationProviders = allowAuthentication
|
||||
? new[] { oneStepAuthentication ? "one-step" : "hash" }
|
||||
? new[]
|
||||
{
|
||||
oneStepAuthentication
|
||||
? "one-step"
|
||||
: PasswordAuthenticationProvider.ProtocolName,
|
||||
}
|
||||
: Array.Empty<string>(),
|
||||
AllowedEncryptionProviders = (encrypted || requireEncryption) && allowEncryption
|
||||
? new[] { AesEncryptionProvider.Name }
|
||||
@@ -148,7 +242,9 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
var cluster = new IntegrationCluster(serverWh, server, port);
|
||||
|
||||
cluster.ClientWarehouse.RegisterAuthenticationProvider(oneStepAuthentication
|
||||
? new OneStepAuthenticationProvider(mismatchedSessionKeys ? (byte)0x80 : (byte)0)
|
||||
? new OneStepAuthenticationProvider(
|
||||
mismatchedSessionKeys ? (byte)0x80 : (byte)0,
|
||||
requireKeyRotation)
|
||||
: new TestClientAuthProvider());
|
||||
if (encrypted)
|
||||
cluster.ClientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
|
||||
@@ -161,7 +257,9 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
{
|
||||
AuthenticationMode = AuthenticationMode.InitializerIdentity,
|
||||
Identity = "tester",
|
||||
AuthenticationProtocol = oneStepAuthentication ? "one-step" : "hash",
|
||||
AuthenticationProtocol = oneStepAuthentication
|
||||
? "one-step"
|
||||
: PasswordAuthenticationProvider.ProtocolName,
|
||||
Domain = "test",
|
||||
EncryptionMode = encrypted
|
||||
? encryptionMode
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
namespace Esiur.Tests.Unit.Integration;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class PpapIntegrationTests
|
||||
{
|
||||
const string Domain = "ppap.test";
|
||||
const string Identity = "alice";
|
||||
const string Password = "correct horse battery staple";
|
||||
const string ServerIdentity = "server";
|
||||
const string ServerPassword = "server paper password";
|
||||
|
||||
static readonly PpapKdfProfile TestKdf = new(
|
||||
PpapKdfProfile.Argon2Version13,
|
||||
memoryKiB: 8 * 1024,
|
||||
iterations: 1,
|
||||
parallelism: 1);
|
||||
|
||||
[Fact]
|
||||
public async Task InitializerPassword_AuthenticatesWithAesAndRotatesOnlyAfterEncryption()
|
||||
{
|
||||
using var localIdentity = PpapLocalIdentity.FromPassword(
|
||||
Identity,
|
||||
Password,
|
||||
TestKdf);
|
||||
var initial = PpapRegistrationRecord.FromLocalIdentity(Domain, localIdentity);
|
||||
var serverStore = new ObservedPpapRegistrationStore();
|
||||
Assert.True(serverStore.TryAdd(Domain, initial));
|
||||
|
||||
var clientProvider = new PpapAuthenticationProvider(
|
||||
localIdentity,
|
||||
new InMemoryPpapRegistrationStore());
|
||||
var serverProvider = new PpapAuthenticationProvider(
|
||||
localIdentity: null!,
|
||||
registrations: serverStore);
|
||||
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
|
||||
serverProvider,
|
||||
clientProvider,
|
||||
encrypted: true,
|
||||
populate: async warehouse =>
|
||||
await warehouse.Put("sys/ppap-rotated", new Node { Id = 313 }),
|
||||
authenticationMode: AuthenticationMode.InitializerIdentity,
|
||||
identity: Identity,
|
||||
domain: Domain,
|
||||
serverCreated: value =>
|
||||
{
|
||||
serverStore.EncryptionProbe = () =>
|
||||
value.Connections.SingleOrDefault()?.IsEncrypted == true;
|
||||
})
|
||||
.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
|
||||
var serverConnection = Assert.Single(cluster.Server.Connections);
|
||||
var rotated = Assert.IsType<PpapRegistrationRecord>(
|
||||
serverStore.Get(Domain, Identity));
|
||||
|
||||
Assert.True(cluster.Connection.Session.Authenticated);
|
||||
Assert.True(serverConnection.Session.Authenticated);
|
||||
Assert.True(cluster.Connection.IsEncrypted);
|
||||
Assert.True(serverConnection.IsEncrypted);
|
||||
Assert.Equal(PpapProtocol.Name,
|
||||
cluster.Connection.Session.AuthenticationHandler.Protocol);
|
||||
Assert.Equal(PpapProtocol.SessionKeyLength,
|
||||
cluster.Connection.Session.Key.Length);
|
||||
Assert.Equal(cluster.Connection.Session.Key, serverConnection.Session.Key);
|
||||
|
||||
Assert.True(serverStore.EncryptionObservedAtSuccessfulRotation);
|
||||
Assert.Equal(initial.Version + 1, rotated.Version);
|
||||
Assert.False(initial.Nonce.SequenceEqual(rotated.Nonce));
|
||||
Assert.False(initial.EncapsulationKey.SequenceEqual(rotated.EncapsulationKey));
|
||||
|
||||
Assert.NotNull(await Task.Run(async () =>
|
||||
await cluster.Connection.Get("sys/ppap-rotated"))
|
||||
.WaitAsync(TimeSpan.FromSeconds(10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializerPassword_OmittedDomainDefaultsToHostnameAndRotates()
|
||||
{
|
||||
const string hostnameDomain = "localhost";
|
||||
using var localIdentity = PpapLocalIdentity.FromPassword(
|
||||
Identity,
|
||||
Password,
|
||||
TestKdf);
|
||||
var initial = PpapRegistrationRecord.FromLocalIdentity(
|
||||
hostnameDomain,
|
||||
localIdentity);
|
||||
var serverStore = new ObservedPpapRegistrationStore();
|
||||
Assert.True(serverStore.TryAdd(hostnameDomain, initial));
|
||||
|
||||
var clientProvider = new PpapAuthenticationProvider(
|
||||
localIdentity,
|
||||
new InMemoryPpapRegistrationStore());
|
||||
var serverProvider = new PpapAuthenticationProvider(
|
||||
localIdentity: null!,
|
||||
registrations: serverStore);
|
||||
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
|
||||
serverProvider,
|
||||
clientProvider,
|
||||
encrypted: true,
|
||||
authenticationMode: AuthenticationMode.InitializerIdentity,
|
||||
identity: Identity,
|
||||
domain: null,
|
||||
serverCreated: value =>
|
||||
{
|
||||
serverStore.EncryptionProbe = () =>
|
||||
value.Connections.SingleOrDefault()?.IsEncrypted == true;
|
||||
})
|
||||
.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
|
||||
var serverConnection = Assert.Single(cluster.Server.Connections);
|
||||
var rotated = Assert.IsType<PpapRegistrationRecord>(
|
||||
serverStore.Get(hostnameDomain, Identity));
|
||||
|
||||
Assert.Equal(hostnameDomain, cluster.Connection.RemoteDomain);
|
||||
Assert.True(cluster.Connection.Session.Authenticated);
|
||||
Assert.True(serverConnection.Session.Authenticated);
|
||||
Assert.True(cluster.Connection.IsEncrypted);
|
||||
Assert.True(serverConnection.IsEncrypted);
|
||||
Assert.True(serverStore.EncryptionObservedAtSuccessfulRotation);
|
||||
Assert.Equal(initial.Version + 1, rotated.Version);
|
||||
Assert.False(initial.Nonce.SequenceEqual(rotated.Nonce));
|
||||
Assert.False(initial.EncapsulationKey.SequenceEqual(rotated.EncapsulationKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResponderPassword_AuthenticatesWithAesAndRotatesResponderRegistration()
|
||||
{
|
||||
using var serverIdentity = PpapLocalIdentity.FromPassword(
|
||||
ServerIdentity,
|
||||
ServerPassword,
|
||||
TestKdf);
|
||||
var initial = PpapRegistrationRecord.FromLocalIdentity(
|
||||
Domain,
|
||||
serverIdentity);
|
||||
var clientStore = new ObservedPpapRegistrationStore();
|
||||
Assert.True(clientStore.TryAdd(Domain, initial));
|
||||
|
||||
var clientProvider = new PpapAuthenticationProvider(
|
||||
localIdentity: null!,
|
||||
registrations: clientStore);
|
||||
var serverProvider = new PpapAuthenticationProvider(
|
||||
serverIdentity,
|
||||
new InMemoryPpapRegistrationStore());
|
||||
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
|
||||
serverProvider,
|
||||
clientProvider,
|
||||
encrypted: true,
|
||||
authenticationMode: AuthenticationMode.ResponderIdentity,
|
||||
identity: null,
|
||||
responderIdentity: ServerIdentity,
|
||||
domain: Domain,
|
||||
serverCreated: value =>
|
||||
{
|
||||
clientStore.EncryptionProbe = () =>
|
||||
value.Connections.SingleOrDefault()?.IsEncrypted == true;
|
||||
})
|
||||
.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
|
||||
var serverConnection = Assert.Single(cluster.Server.Connections);
|
||||
var rotated = Assert.IsType<PpapRegistrationRecord>(
|
||||
clientStore.Get(Domain, ServerIdentity));
|
||||
|
||||
AssertEncryptedAndAuthenticated(cluster.Connection, serverConnection);
|
||||
Assert.Null(cluster.Connection.Session.LocalIdentity);
|
||||
Assert.Equal(ServerIdentity, cluster.Connection.Session.RemoteIdentity);
|
||||
Assert.Equal(ServerIdentity, serverConnection.Session.LocalIdentity);
|
||||
Assert.Null(serverConnection.Session.RemoteIdentity);
|
||||
Assert.True(clientStore.EncryptionObservedAtSuccessfulRotation);
|
||||
AssertRotated(initial, rotated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DualPassword_AuthenticatesWithAesAndRotatesBothRegistrations()
|
||||
{
|
||||
using var clientIdentity = PpapLocalIdentity.FromPassword(
|
||||
Identity,
|
||||
Password,
|
||||
TestKdf);
|
||||
using var serverIdentity = PpapLocalIdentity.FromPassword(
|
||||
ServerIdentity,
|
||||
ServerPassword,
|
||||
TestKdf);
|
||||
var initialClient = PpapRegistrationRecord.FromLocalIdentity(
|
||||
Domain,
|
||||
clientIdentity);
|
||||
var initialServer = PpapRegistrationRecord.FromLocalIdentity(
|
||||
Domain,
|
||||
serverIdentity);
|
||||
var serverStore = new ObservedPpapRegistrationStore();
|
||||
var clientStore = new ObservedPpapRegistrationStore();
|
||||
Assert.True(serverStore.TryAdd(Domain, initialClient));
|
||||
Assert.True(clientStore.TryAdd(Domain, initialServer));
|
||||
|
||||
var clientProvider = new PpapAuthenticationProvider(clientIdentity, clientStore);
|
||||
var serverProvider = new PpapAuthenticationProvider(serverIdentity, serverStore);
|
||||
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
|
||||
serverProvider,
|
||||
clientProvider,
|
||||
encrypted: true,
|
||||
authenticationMode: AuthenticationMode.DualIdentity,
|
||||
identity: Identity,
|
||||
responderIdentity: ServerIdentity,
|
||||
domain: Domain,
|
||||
serverCreated: value =>
|
||||
{
|
||||
Func<bool> probe = () =>
|
||||
value.Connections.SingleOrDefault()?.IsEncrypted == true;
|
||||
serverStore.EncryptionProbe = probe;
|
||||
clientStore.EncryptionProbe = probe;
|
||||
})
|
||||
.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
|
||||
var serverConnection = Assert.Single(cluster.Server.Connections);
|
||||
AssertEncryptedAndAuthenticated(cluster.Connection, serverConnection);
|
||||
Assert.Equal(Identity, cluster.Connection.Session.LocalIdentity);
|
||||
Assert.Equal(ServerIdentity, cluster.Connection.Session.RemoteIdentity);
|
||||
Assert.Equal(ServerIdentity, serverConnection.Session.LocalIdentity);
|
||||
Assert.Equal(Identity, serverConnection.Session.RemoteIdentity);
|
||||
Assert.True(serverStore.EncryptionObservedAtSuccessfulRotation);
|
||||
Assert.True(clientStore.EncryptionObservedAtSuccessfulRotation);
|
||||
AssertRotated(initialClient, serverStore.Get(Domain, Identity));
|
||||
AssertRotated(initialServer, clientStore.Get(Domain, ServerIdentity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DualMixedIdentity_RotatesOnlyPasswordRegistration()
|
||||
{
|
||||
using var clientIdentity = PpapLocalIdentity.CreateStatic(Identity);
|
||||
using var serverIdentity = PpapLocalIdentity.FromPassword(
|
||||
ServerIdentity,
|
||||
ServerPassword,
|
||||
TestKdf);
|
||||
var initialClient = PpapRegistrationRecord.FromLocalIdentity(
|
||||
Domain,
|
||||
clientIdentity);
|
||||
var initialServer = PpapRegistrationRecord.FromLocalIdentity(
|
||||
Domain,
|
||||
serverIdentity);
|
||||
var serverStore = new ObservedPpapRegistrationStore();
|
||||
var clientStore = new ObservedPpapRegistrationStore();
|
||||
Assert.True(serverStore.TryAdd(Domain, initialClient));
|
||||
Assert.True(clientStore.TryAdd(Domain, initialServer));
|
||||
|
||||
var clientProvider = new PpapAuthenticationProvider(clientIdentity, clientStore);
|
||||
var serverProvider = new PpapAuthenticationProvider(serverIdentity, serverStore);
|
||||
await using var cluster = await CustomAuthenticationIntegrationCluster.StartAsync(
|
||||
serverProvider,
|
||||
clientProvider,
|
||||
encrypted: true,
|
||||
authenticationMode: AuthenticationMode.DualIdentity,
|
||||
identity: Identity,
|
||||
responderIdentity: ServerIdentity,
|
||||
domain: Domain,
|
||||
serverCreated: value =>
|
||||
{
|
||||
Func<bool> probe = () =>
|
||||
value.Connections.SingleOrDefault()?.IsEncrypted == true;
|
||||
serverStore.EncryptionProbe = probe;
|
||||
clientStore.EncryptionProbe = probe;
|
||||
})
|
||||
.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
|
||||
var serverConnection = Assert.Single(cluster.Server.Connections);
|
||||
AssertEncryptedAndAuthenticated(cluster.Connection, serverConnection);
|
||||
var unchangedClient = serverStore.Get(Domain, Identity);
|
||||
Assert.Equal(initialClient.Version, unchangedClient.Version);
|
||||
Assert.Equal(initialClient.EncapsulationKey, unchangedClient.EncapsulationKey);
|
||||
Assert.False(serverStore.EncryptionObservedAtSuccessfulRotation);
|
||||
Assert.True(clientStore.EncryptionObservedAtSuccessfulRotation);
|
||||
AssertRotated(initialServer, clientStore.Get(Domain, ServerIdentity));
|
||||
}
|
||||
|
||||
static void AssertEncryptedAndAuthenticated(
|
||||
Esiur.Protocol.EpConnection client,
|
||||
Esiur.Protocol.EpConnection server)
|
||||
{
|
||||
Assert.True(client.Session.Authenticated);
|
||||
Assert.True(server.Session.Authenticated);
|
||||
Assert.True(client.IsEncrypted);
|
||||
Assert.True(server.IsEncrypted);
|
||||
Assert.Equal(client.Session.Key, server.Session.Key);
|
||||
}
|
||||
|
||||
static void AssertRotated(
|
||||
PpapRegistrationRecord initial,
|
||||
PpapRegistrationRecord rotated)
|
||||
{
|
||||
Assert.NotNull(rotated);
|
||||
Assert.Equal(initial.Version + 1, rotated.Version);
|
||||
Assert.False(initial.Nonce.SequenceEqual(rotated.Nonce));
|
||||
Assert.False(initial.EncapsulationKey.SequenceEqual(rotated.EncapsulationKey));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ObservedPpapRegistrationStore : IPpapRegistrationStore
|
||||
{
|
||||
readonly InMemoryPpapRegistrationStore _inner = new();
|
||||
int _encryptionObservedAtSuccessfulRotation;
|
||||
|
||||
public Func<bool>? EncryptionProbe { get; set; }
|
||||
|
||||
public bool EncryptionObservedAtSuccessfulRotation
|
||||
=> Volatile.Read(ref _encryptionObservedAtSuccessfulRotation) != 0;
|
||||
|
||||
public bool TryAdd(string domain, PpapRegistrationRecord record)
|
||||
=> _inner.TryAdd(domain, record);
|
||||
|
||||
public PpapRegistrationRecord Get(string domain, string identity)
|
||||
=> _inner.Get(domain, identity);
|
||||
|
||||
public PpapRegistrationRecord ResolveMasked(
|
||||
string domain,
|
||||
byte[] mask,
|
||||
byte[] maskKey,
|
||||
byte[] maskedIdentity)
|
||||
=> _inner.ResolveMasked(domain, mask, maskKey, maskedIdentity);
|
||||
|
||||
public bool TryRotate(
|
||||
string domain,
|
||||
string identity,
|
||||
long expectedVersion,
|
||||
PpapRegistrationRecord replacement)
|
||||
{
|
||||
var encrypted = EncryptionProbe?.Invoke() == true;
|
||||
var rotated = _inner.TryRotate(
|
||||
domain,
|
||||
identity,
|
||||
expectedVersion,
|
||||
replacement);
|
||||
if (rotated && encrypted)
|
||||
Interlocked.Exchange(ref _encryptionObservedAtSuccessfulRotation, 1);
|
||||
return rotated;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ namespace Esiur.Tests.Unit.Integration;
|
||||
using Esiur.Data;
|
||||
using Esiur.Net.Sockets;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
using Esiur.Security.Cryptography;
|
||||
|
||||
[Collection("Integration")]
|
||||
@@ -21,7 +22,8 @@ public class SessionHeadersIntegrationTests
|
||||
Assert.Null(cluster.Connection.Session.RemoteHeaders.AuthenticationData);
|
||||
|
||||
Assert.Equal("test", serverConnection.Session.RemoteHeaders.Domain);
|
||||
Assert.Equal("hash", serverConnection.Session.RemoteHeaders.AuthenticationProtocol);
|
||||
Assert.Equal(PasswordAuthenticationProvider.ProtocolName,
|
||||
serverConnection.Session.RemoteHeaders.AuthenticationProtocol);
|
||||
Assert.Null(serverConnection.Session.RemoteHeaders.AuthenticationData);
|
||||
}
|
||||
|
||||
@@ -159,6 +161,50 @@ public class SessionHeadersIntegrationTests
|
||||
Assert.Equal(117, Convert.ToInt32(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequiredAuthenticationKeyRotation_CompletesBeforeConnectionsBecomeReady()
|
||||
{
|
||||
await using var cluster = await IntegrationCluster
|
||||
.StartAsync(
|
||||
async warehouse =>
|
||||
{
|
||||
await warehouse.Put("sys/key-rotation", new EncryptedEchoResource());
|
||||
},
|
||||
encrypted: true,
|
||||
oneStepAuthentication: true,
|
||||
requireKeyRotation: true)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
var clientHandler = Assert.IsType<OneStepAuthenticationHandler>(
|
||||
cluster.Connection.Session.AuthenticationHandler);
|
||||
var serverConnection = Assert.Single(cluster.Server.Connections);
|
||||
var serverHandler = Assert.IsType<OneStepAuthenticationHandler>(
|
||||
serverConnection.Session.AuthenticationHandler);
|
||||
|
||||
Assert.True(clientHandler.KeyRotationCompleted);
|
||||
Assert.True(serverHandler.KeyRotationCompleted);
|
||||
Assert.True(cluster.Connection.IsEncrypted);
|
||||
Assert.True(serverConnection.IsEncrypted);
|
||||
|
||||
Assert.NotNull(await Task.Run(async () =>
|
||||
await cluster.Connection.Get("sys/key-rotation"))
|
||||
.WaitAsync(TimeSpan.FromSeconds(10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequiredAuthenticationKeyRotation_RejectsUnencryptedSession()
|
||||
{
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await IntegrationCluster.StartAsync(
|
||||
_ => Task.CompletedTask,
|
||||
encrypted: false,
|
||||
oneStepAuthentication: true,
|
||||
requireKeyRotation: true)
|
||||
.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
|
||||
Assert.IsNotType<TimeoutException>(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocketTransport_EncryptsFetchAndRpc()
|
||||
{
|
||||
|
||||
@@ -49,6 +49,103 @@ public class PeerConnectionLimitTests
|
||||
replacementSocket.Close();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_RejectsRepeatedConnectionsAbovePerIpAttemptRate()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 0;
|
||||
warehouse.Configuration.Connections.MaximumConnectionAttemptsPerIpAddress = 2;
|
||||
warehouse.Configuration.Connections.ConnectionAttemptWindow = TimeSpan.FromMinutes(1);
|
||||
|
||||
var server = new EpServer();
|
||||
server.Instance = new Instance(warehouse, 1, "server", server, null);
|
||||
server.Connections = new AutoList<EpConnection, NetworkServer<EpConnection>>(server);
|
||||
var address = IPAddress.Parse("192.0.2.20");
|
||||
|
||||
for (var index = 0; index < 2; index++)
|
||||
{
|
||||
var admittedSocket = new TestSocket(address, 11000 + index);
|
||||
var admitted = new EpConnection();
|
||||
admitted.Assign(admittedSocket);
|
||||
server.Add(admitted);
|
||||
Assert.Equal(SocketState.Established, admittedSocket.State);
|
||||
admittedSocket.Close();
|
||||
}
|
||||
|
||||
var rejectedSocket = new TestSocket(address, 11002);
|
||||
var rejected = new EpConnection();
|
||||
rejected.Assign(rejectedSocket);
|
||||
server.Add(rejected);
|
||||
|
||||
Assert.Equal(SocketState.Closed, rejectedSocket.State);
|
||||
Assert.Empty(server.Connections);
|
||||
|
||||
var otherSocket = new TestSocket(IPAddress.Parse("192.0.2.21"), 11003);
|
||||
var other = new EpConnection();
|
||||
other.Assign(otherSocket);
|
||||
server.Add(other);
|
||||
Assert.Equal(SocketState.Established, otherSocket.State);
|
||||
otherSocket.Close();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_ClosesStalledAuthenticationAtDeadlineAndReleasesSlot()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 1;
|
||||
var server = new EpServer
|
||||
{
|
||||
AuthenticationTimeout = TimeSpan.FromMilliseconds(50),
|
||||
};
|
||||
server.Instance = new Instance(warehouse, 1, "server", server, null);
|
||||
server.Connections = new AutoList<EpConnection, NetworkServer<EpConnection>>(server);
|
||||
|
||||
var address = IPAddress.Parse("192.0.2.30");
|
||||
var socket = new TestSocket(address, 12000);
|
||||
var connection = new EpConnection();
|
||||
connection.Assign(socket);
|
||||
server.Add(connection);
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3);
|
||||
while ((socket.State != SocketState.Closed
|
||||
|| server.GetConnectionCount(address) != 0
|
||||
|| server.Connections.Count != 0)
|
||||
&& DateTime.UtcNow < deadline)
|
||||
await Task.Delay(10);
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.Equal(0, server.GetConnectionCount(address));
|
||||
Assert.Empty(server.Connections);
|
||||
Assert.False(connection.Session.Authenticated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_ClosesStalledAuthenticationAtDeadline()
|
||||
{
|
||||
var socket = new TestSocket(IPAddress.Parse("192.0.2.40"), 13000);
|
||||
var connection = new EpConnection
|
||||
{
|
||||
AuthenticationTimeout = TimeSpan.FromMilliseconds(50),
|
||||
};
|
||||
var warehouse = new Warehouse();
|
||||
connection.Instance = new Instance(
|
||||
warehouse,
|
||||
1,
|
||||
"example.test",
|
||||
connection,
|
||||
null);
|
||||
|
||||
_ = connection.Connect(socket, "example.test", 10518, "example.test");
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3);
|
||||
while (socket.State != SocketState.Closed && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(10);
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.False(connection.Session.Authenticated);
|
||||
Assert.Equal(EpConnectionStatus.Closed, connection.Status);
|
||||
}
|
||||
|
||||
sealed class TestSocket : ISocket
|
||||
{
|
||||
public event DestroyedEvent? OnDestroy;
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
using System.Buffers.Binary;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class PpapAuthenticationTests
|
||||
{
|
||||
const string Domain = "example.test";
|
||||
const string InitiatorIdentity = "alice";
|
||||
const string ResponderIdentity = "server";
|
||||
const string InitiatorPassword = "correct horse battery staple";
|
||||
const string ResponderPassword = "server paper password";
|
||||
|
||||
static readonly PpapKdfProfile TestKdf = new(
|
||||
PpapKdfProfile.Argon2Version13,
|
||||
memoryKiB: 8 * 1024,
|
||||
iterations: 1,
|
||||
parallelism: 1);
|
||||
|
||||
[Theory]
|
||||
[InlineData(AuthenticationMode.InitializerIdentity)]
|
||||
[InlineData(AuthenticationMode.ResponderIdentity)]
|
||||
[InlineData(AuthenticationMode.DualIdentity)]
|
||||
public void PasswordHandshake_AllIdentityModesDeriveMatchingKeys(
|
||||
AuthenticationMode mode)
|
||||
{
|
||||
using var pair = CreatePair(mode);
|
||||
|
||||
var result = CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
|
||||
var authenticatesInitiator = mode is AuthenticationMode.InitializerIdentity
|
||||
or AuthenticationMode.DualIdentity;
|
||||
var authenticatesResponder = mode is AuthenticationMode.ResponderIdentity
|
||||
or AuthenticationMode.DualIdentity;
|
||||
|
||||
Assert.Equal(PpapProtocol.SessionKeyLength, result.Initiator.SessionKey.Length);
|
||||
Assert.Equal(result.Initiator.SessionKey, result.Responder.SessionKey);
|
||||
Assert.Equal(authenticatesInitiator ? InitiatorIdentity : null,
|
||||
result.Initiator.LocalIdentity);
|
||||
Assert.Equal(authenticatesResponder ? ResponderIdentity : null,
|
||||
result.Initiator.RemoteIdentity);
|
||||
Assert.Equal(authenticatesResponder ? ResponderIdentity : null,
|
||||
result.Responder.LocalIdentity);
|
||||
Assert.Equal(authenticatesInitiator ? InitiatorIdentity : null,
|
||||
result.Responder.RemoteIdentity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongPassword_FailsWithoutChangingRegistration()
|
||||
{
|
||||
using var pair = CreatePair(
|
||||
AuthenticationMode.InitializerIdentity,
|
||||
initiatorPassword: "not the registered password");
|
||||
var before = Snapshot(pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(Wire(second, PpapMessageType.ServerHello));
|
||||
var fourth = pair.Responder.Process(Wire(third, PpapMessageType.InitiatorProof));
|
||||
var failed = pair.Initiator.Process(Wire(fourth, PpapMessageType.ResponderProof));
|
||||
|
||||
Assert.Equal(AuthenticationRuling.Failed, failed.Ruling);
|
||||
Assert.Null(failed.SessionKey);
|
||||
AssertSnapshot(before, pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedResponderFinished_FailsClosed()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.InitializerIdentity);
|
||||
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(Wire(second, PpapMessageType.ServerHello));
|
||||
var fourth = pair.Responder.Process(Wire(third, PpapMessageType.InitiatorProof));
|
||||
var tampered = TamperLastByte(Wire(fourth, PpapMessageType.ResponderProof));
|
||||
|
||||
var failed = pair.Initiator.Process(tampered);
|
||||
|
||||
Assert.Equal(AuthenticationRuling.Failed, failed.Ruling);
|
||||
Assert.Null(failed.SessionKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedInitiatorFinished_FailsClosed()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.InitializerIdentity);
|
||||
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(Wire(second, PpapMessageType.ServerHello));
|
||||
var fourth = pair.Responder.Process(Wire(third, PpapMessageType.InitiatorProof));
|
||||
var fifth = pair.Initiator.Process(Wire(fourth, PpapMessageType.ResponderProof));
|
||||
var tampered = TamperLastByte(Wire(fifth, PpapMessageType.InitiatorFinished));
|
||||
|
||||
var failed = pair.Responder.Process(tampered);
|
||||
|
||||
Assert.Equal(AuthenticationRuling.Failed, failed.Ruling);
|
||||
Assert.Null(failed.SessionKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DualIdentityProofs_DoNotExposeRegistrationDescriptors()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.DualIdentity);
|
||||
var initiatorRegistration = pair.ResponderStore.Get(
|
||||
Domain,
|
||||
InitiatorIdentity);
|
||||
var responderRegistration = pair.InitiatorStore.Get(
|
||||
Domain,
|
||||
ResponderIdentity);
|
||||
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(
|
||||
Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(
|
||||
Wire(second, PpapMessageType.ServerHello));
|
||||
var initiatorProof = Wire(third, PpapMessageType.InitiatorProof);
|
||||
var fourth = pair.Responder.Process(initiatorProof);
|
||||
var responderProof = Wire(fourth, PpapMessageType.ResponderProof);
|
||||
|
||||
AssertDescriptorNotVisible(initiatorProof, responderRegistration);
|
||||
AssertDescriptorNotVisible(responderProof, initiatorRegistration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedEncryptedResponderDescriptor_FailsClosed()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.DualIdentity);
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(
|
||||
Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(
|
||||
Wire(second, PpapMessageType.ServerHello));
|
||||
var tampered = TamperField(
|
||||
Wire(third, PpapMessageType.InitiatorProof),
|
||||
fieldIndex: 2);
|
||||
|
||||
var failed = pair.Responder.Process(tampered);
|
||||
|
||||
Assert.Equal(AuthenticationRuling.Failed, failed.Ruling);
|
||||
Assert.Null(failed.SessionKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedEncryptedInitiatorDescriptor_FailsClosed()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.DualIdentity);
|
||||
var first = pair.Initiator.Process(null!);
|
||||
var second = pair.Responder.Process(
|
||||
Wire(first, PpapMessageType.ClientHello));
|
||||
var third = pair.Initiator.Process(
|
||||
Wire(second, PpapMessageType.ServerHello));
|
||||
var fourth = pair.Responder.Process(
|
||||
Wire(third, PpapMessageType.InitiatorProof));
|
||||
var tampered = TamperField(
|
||||
Wire(fourth, PpapMessageType.ResponderProof),
|
||||
fieldIndex: 1);
|
||||
|
||||
var failed = pair.Initiator.Process(tampered);
|
||||
|
||||
Assert.Equal(AuthenticationRuling.Failed, failed.Ruling);
|
||||
Assert.Null(failed.SessionKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtectedDescriptor_DiffersAcrossFreshSessionsForSameRegistration()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.DualIdentity);
|
||||
var firstProof = CreateInitiatorProof(pair.Initiator, pair.Responder);
|
||||
var initiatorProvider = Assert.IsType<PpapAuthenticationProvider>(
|
||||
pair.Initiator.Provider);
|
||||
var responderProvider = Assert.IsType<PpapAuthenticationProvider>(
|
||||
pair.Responder.Provider);
|
||||
using var secondInitiator = CreateHandler(
|
||||
initiatorProvider,
|
||||
AuthenticationDirection.Initiator,
|
||||
AuthenticationMode.DualIdentity);
|
||||
using var secondResponder = CreateHandler(
|
||||
responderProvider,
|
||||
AuthenticationDirection.Responder,
|
||||
AuthenticationMode.DualIdentity);
|
||||
var secondProof = CreateInitiatorProof(secondInitiator, secondResponder);
|
||||
|
||||
var firstDescriptor = ExtractField(firstProof, fieldIndex: 2);
|
||||
var secondDescriptor = ExtractField(secondProof, fieldIndex: 2);
|
||||
|
||||
Assert.Equal(144, firstDescriptor.Length);
|
||||
Assert.Equal(PpapProtocol.ProtectedDescriptorLength, firstDescriptor.Length);
|
||||
Assert.Equal(144, secondDescriptor.Length);
|
||||
Assert.False(firstDescriptor.SequenceEqual(secondDescriptor));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializerPassword_RotationChangesVersionNonceAndKey()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.InitializerIdentity);
|
||||
CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
var before = Snapshot(pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
|
||||
CompleteInitializerRotation(pair.Initiator, pair.Responder);
|
||||
|
||||
var after = pair.ResponderStore.Get(Domain, InitiatorIdentity);
|
||||
Assert.Equal(before.Version + 1, after.Version);
|
||||
Assert.False(before.Nonce.SequenceEqual(after.Nonce));
|
||||
Assert.False(before.EncapsulationKey.SequenceEqual(after.EncapsulationKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponderPassword_RotationChangesVerifierRecord()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.ResponderIdentity);
|
||||
CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
var before = Snapshot(pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
|
||||
CompleteResponderRotation(pair.Initiator, pair.Responder);
|
||||
|
||||
AssertRotated(
|
||||
before,
|
||||
pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DualPassword_RotationChangesBothVerifierRecords()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.DualIdentity);
|
||||
CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
var initiatorBefore = Snapshot(
|
||||
pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
var responderBefore = Snapshot(
|
||||
pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
|
||||
CompleteDualRotation(pair.Initiator, pair.Responder);
|
||||
|
||||
AssertRotated(
|
||||
initiatorBefore,
|
||||
pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
AssertRotated(
|
||||
responderBefore,
|
||||
pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedRotationProof_FailsWithoutPersistingOffer()
|
||||
{
|
||||
using var pair = CreatePair(AuthenticationMode.InitializerIdentity);
|
||||
CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
var before = Snapshot(pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
|
||||
var offer = pair.Initiator.BeginKeyRotation();
|
||||
var challenge = pair.Responder.ProcessKeyRotation(
|
||||
RotationWire(offer, PpapMessageType.RotationOffer));
|
||||
var proof = pair.Initiator.ProcessKeyRotation(
|
||||
RotationWire(challenge, PpapMessageType.RotationChallenge));
|
||||
var tampered = TamperLastByte(
|
||||
RotationWire(proof, PpapMessageType.RotationProof));
|
||||
|
||||
var failed = pair.Responder.ProcessKeyRotation(tampered);
|
||||
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Failed, failed.Ruling);
|
||||
AssertSnapshot(before, pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticDualIdentity_UsesEncryptedNoOpCompletionWithoutChangingRecords()
|
||||
{
|
||||
using var pair = CreateStaticPair();
|
||||
var initiatorBefore = Snapshot(
|
||||
pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
var responderBefore = Snapshot(
|
||||
pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
CompleteHandshake(pair.Initiator, pair.Responder);
|
||||
|
||||
var done = pair.Initiator.BeginKeyRotation();
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, done.Ruling);
|
||||
var completed = pair.Responder.ProcessKeyRotation(
|
||||
RotationWire(done, PpapMessageType.RotationDone));
|
||||
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, completed.Ruling);
|
||||
Assert.Null(completed.Data);
|
||||
AssertSnapshot(
|
||||
initiatorBefore,
|
||||
pair.ResponderStore.Get(Domain, InitiatorIdentity));
|
||||
AssertSnapshot(
|
||||
responderBefore,
|
||||
pair.InitiatorStore.Get(Domain, ResponderIdentity));
|
||||
}
|
||||
|
||||
static HandshakeResult CompleteHandshake(
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
var first = initiator.Process(null!);
|
||||
Assert.Equal(AuthenticationRuling.InProgress, first.Ruling);
|
||||
|
||||
var second = responder.Process(Wire(first, PpapMessageType.ClientHello));
|
||||
Assert.Equal(AuthenticationRuling.InProgress, second.Ruling);
|
||||
|
||||
var third = initiator.Process(Wire(second, PpapMessageType.ServerHello));
|
||||
Assert.Equal(AuthenticationRuling.InProgress, third.Ruling);
|
||||
|
||||
var fourth = responder.Process(Wire(third, PpapMessageType.InitiatorProof));
|
||||
Assert.Equal(AuthenticationRuling.InProgress, fourth.Ruling);
|
||||
|
||||
var fifth = initiator.Process(Wire(fourth, PpapMessageType.ResponderProof));
|
||||
Assert.Equal(AuthenticationRuling.Succeeded, fifth.Ruling);
|
||||
|
||||
var sixth = responder.Process(Wire(fifth, PpapMessageType.InitiatorFinished));
|
||||
Assert.Equal(AuthenticationRuling.Succeeded, sixth.Ruling);
|
||||
|
||||
return new HandshakeResult(fifth, sixth);
|
||||
}
|
||||
|
||||
static byte[] CreateInitiatorProof(
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
var first = initiator.Process(null!);
|
||||
var second = responder.Process(Wire(first, PpapMessageType.ClientHello));
|
||||
var third = initiator.Process(Wire(second, PpapMessageType.ServerHello));
|
||||
return Wire(third, PpapMessageType.InitiatorProof);
|
||||
}
|
||||
|
||||
static PpapAuthenticationHandler CreateHandler(
|
||||
PpapAuthenticationProvider provider,
|
||||
AuthenticationDirection direction,
|
||||
AuthenticationMode mode)
|
||||
=> Assert.IsType<PpapAuthenticationHandler>(
|
||||
provider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = direction,
|
||||
Mode = mode,
|
||||
Domain = Domain,
|
||||
InitiatorIdentity = InitiatorIdentity,
|
||||
ResponderIdentity = ResponderIdentity,
|
||||
HostName = ResponderIdentity,
|
||||
}));
|
||||
|
||||
static void CompleteInitializerRotation(
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
Assert.True(initiator.RequiresKeyRotation);
|
||||
Assert.True(responder.RequiresKeyRotation);
|
||||
|
||||
var offer = initiator.BeginKeyRotation();
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.InProgress, offer.Ruling);
|
||||
|
||||
var challenge = responder.ProcessKeyRotation(
|
||||
RotationWire(offer, PpapMessageType.RotationOffer));
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.InProgress, challenge.Ruling);
|
||||
|
||||
var proof = initiator.ProcessKeyRotation(
|
||||
RotationWire(challenge, PpapMessageType.RotationChallenge));
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.InProgress, proof.Ruling);
|
||||
|
||||
var commit = responder.ProcessKeyRotation(
|
||||
RotationWire(proof, PpapMessageType.RotationProof));
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.InProgress, commit.Ruling);
|
||||
|
||||
var done = initiator.ProcessKeyRotation(
|
||||
RotationWire(commit, PpapMessageType.RotationCommit));
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, done.Ruling);
|
||||
|
||||
var completed = responder.ProcessKeyRotation(
|
||||
RotationWire(done, PpapMessageType.RotationDone));
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, completed.Ruling);
|
||||
Assert.Null(completed.Data);
|
||||
}
|
||||
|
||||
static void CompleteResponderRotation(
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
var start = initiator.BeginKeyRotation();
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.InProgress, start.Ruling);
|
||||
|
||||
var offer = responder.ProcessKeyRotation(
|
||||
RotationWire(start, PpapMessageType.RotationStart));
|
||||
var challenge = initiator.ProcessKeyRotation(
|
||||
RotationWire(offer, PpapMessageType.RotationOffer));
|
||||
var proof = responder.ProcessKeyRotation(
|
||||
RotationWire(challenge, PpapMessageType.RotationChallenge));
|
||||
var commit = initiator.ProcessKeyRotation(
|
||||
RotationWire(proof, PpapMessageType.RotationProof));
|
||||
var acknowledgement = responder.ProcessKeyRotation(
|
||||
RotationWire(commit, PpapMessageType.RotationCommit));
|
||||
var done = initiator.ProcessKeyRotation(
|
||||
RotationWire(acknowledgement, PpapMessageType.RotationCommitAck));
|
||||
var completed = responder.ProcessKeyRotation(
|
||||
RotationWire(done, PpapMessageType.RotationDone));
|
||||
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, done.Ruling);
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, completed.Ruling);
|
||||
}
|
||||
|
||||
static void CompleteDualRotation(
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
var initiatorOffer = initiator.BeginKeyRotation();
|
||||
var initiatorChallenge = responder.ProcessKeyRotation(
|
||||
RotationWire(initiatorOffer, PpapMessageType.RotationOffer));
|
||||
var initiatorProof = initiator.ProcessKeyRotation(
|
||||
RotationWire(initiatorChallenge, PpapMessageType.RotationChallenge));
|
||||
var initiatorCommit = responder.ProcessKeyRotation(
|
||||
RotationWire(initiatorProof, PpapMessageType.RotationProof));
|
||||
var responderStart = initiator.ProcessKeyRotation(
|
||||
RotationWire(initiatorCommit, PpapMessageType.RotationCommit));
|
||||
|
||||
var responderOffer = responder.ProcessKeyRotation(
|
||||
RotationWire(responderStart, PpapMessageType.RotationStart));
|
||||
var responderChallenge = initiator.ProcessKeyRotation(
|
||||
RotationWire(responderOffer, PpapMessageType.RotationOffer));
|
||||
var responderProof = responder.ProcessKeyRotation(
|
||||
RotationWire(responderChallenge, PpapMessageType.RotationChallenge));
|
||||
var responderCommit = initiator.ProcessKeyRotation(
|
||||
RotationWire(responderProof, PpapMessageType.RotationProof));
|
||||
var acknowledgement = responder.ProcessKeyRotation(
|
||||
RotationWire(responderCommit, PpapMessageType.RotationCommit));
|
||||
var done = initiator.ProcessKeyRotation(
|
||||
RotationWire(acknowledgement, PpapMessageType.RotationCommitAck));
|
||||
var completed = responder.ProcessKeyRotation(
|
||||
RotationWire(done, PpapMessageType.RotationDone));
|
||||
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, done.Ruling);
|
||||
Assert.Equal(AuthenticationKeyRotationRuling.Succeeded, completed.Ruling);
|
||||
}
|
||||
|
||||
static byte[] Wire(AuthenticationResult result, PpapMessageType expectedType)
|
||||
{
|
||||
var wire = Assert.IsType<byte[]>(result.AuthenticationData);
|
||||
Assert.True(wire.Length >= 10);
|
||||
Assert.Equal(expectedType, (PpapMessageType)wire[5]);
|
||||
return wire;
|
||||
}
|
||||
|
||||
static byte[] RotationWire(
|
||||
AuthenticationKeyRotationResult result,
|
||||
PpapMessageType expectedType)
|
||||
{
|
||||
var wire = Assert.IsType<byte[]>(result.Data);
|
||||
Assert.True(wire.Length >= 10);
|
||||
Assert.Equal(expectedType, (PpapMessageType)wire[5]);
|
||||
return wire;
|
||||
}
|
||||
|
||||
static byte[] TamperLastByte(byte[] wire)
|
||||
{
|
||||
var tampered = (byte[])wire.Clone();
|
||||
tampered[^1] ^= 0x80;
|
||||
return tampered;
|
||||
}
|
||||
|
||||
static byte[] TamperField(byte[] wire, int fieldIndex)
|
||||
{
|
||||
var tampered = (byte[])wire.Clone();
|
||||
var offset = 10;
|
||||
for (var index = 0; index <= fieldIndex; index++)
|
||||
{
|
||||
Assert.True(tampered.Length - offset >= sizeof(int));
|
||||
var length = BinaryPrimitives.ReadInt32BigEndian(
|
||||
tampered.AsSpan(offset, sizeof(int)));
|
||||
offset += sizeof(int);
|
||||
Assert.InRange(length, 1, tampered.Length - offset);
|
||||
|
||||
if (index == fieldIndex)
|
||||
{
|
||||
tampered[offset + (length / 2)] ^= 0x80;
|
||||
return tampered;
|
||||
}
|
||||
|
||||
offset += length;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("The requested PPAP field was not found.");
|
||||
}
|
||||
|
||||
static byte[] ExtractField(byte[] wire, int fieldIndex)
|
||||
{
|
||||
var offset = 10;
|
||||
for (var index = 0; index <= fieldIndex; index++)
|
||||
{
|
||||
Assert.True(wire.Length - offset >= sizeof(int));
|
||||
var length = BinaryPrimitives.ReadInt32BigEndian(
|
||||
wire.AsSpan(offset, sizeof(int)));
|
||||
offset += sizeof(int);
|
||||
Assert.InRange(length, 1, wire.Length - offset);
|
||||
|
||||
if (index == fieldIndex)
|
||||
return wire.AsSpan(offset, length).ToArray();
|
||||
|
||||
offset += length;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("The requested PPAP field was not found.");
|
||||
}
|
||||
|
||||
static void AssertDescriptorNotVisible(
|
||||
byte[] wire,
|
||||
PpapRegistrationRecord registration)
|
||||
{
|
||||
Assert.False(ContainsSubsequence(wire, registration.Nonce),
|
||||
"The PPAP proof contains the plaintext registration nonce.");
|
||||
Assert.False(ContainsSubsequence(
|
||||
wire,
|
||||
EncodeDescriptorVersionPrefix(registration)),
|
||||
"The PPAP proof contains the plaintext registration version descriptor.");
|
||||
Assert.False(ContainsSubsequence(
|
||||
wire,
|
||||
EncodeDescriptorProfile(registration.KdfProfile)),
|
||||
"The PPAP proof contains the plaintext registration KDF descriptor.");
|
||||
}
|
||||
|
||||
static byte[] EncodeDescriptorVersionPrefix(PpapRegistrationRecord registration)
|
||||
{
|
||||
var encoded = new byte[1 + sizeof(long) + sizeof(int)];
|
||||
encoded[0] = (byte)registration.Kind;
|
||||
BinaryPrimitives.WriteInt64BigEndian(
|
||||
encoded.AsSpan(1, sizeof(long)),
|
||||
registration.Version);
|
||||
BinaryPrimitives.WriteInt32BigEndian(
|
||||
encoded.AsSpan(1 + sizeof(long), sizeof(int)),
|
||||
registration.Nonce.Length);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
static byte[] EncodeDescriptorProfile(PpapKdfProfile profile)
|
||||
{
|
||||
Assert.NotNull(profile);
|
||||
var encoded = new byte[1 + (4 * sizeof(int))];
|
||||
encoded[0] = 1;
|
||||
BinaryPrimitives.WriteInt32BigEndian(
|
||||
encoded.AsSpan(1, sizeof(int)),
|
||||
profile.Version);
|
||||
BinaryPrimitives.WriteInt32BigEndian(
|
||||
encoded.AsSpan(1 + sizeof(int), sizeof(int)),
|
||||
profile.MemoryKiB);
|
||||
BinaryPrimitives.WriteInt32BigEndian(
|
||||
encoded.AsSpan(1 + (2 * sizeof(int)), sizeof(int)),
|
||||
profile.Iterations);
|
||||
BinaryPrimitives.WriteInt32BigEndian(
|
||||
encoded.AsSpan(1 + (3 * sizeof(int)), sizeof(int)),
|
||||
profile.Parallelism);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
static bool ContainsSubsequence(byte[] value, byte[] candidate)
|
||||
{
|
||||
if (candidate.Length == 0 || candidate.Length > value.Length)
|
||||
return false;
|
||||
|
||||
for (var offset = 0; offset <= value.Length - candidate.Length; offset++)
|
||||
{
|
||||
if (value.AsSpan(offset, candidate.Length).SequenceEqual(candidate))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static RegistrationSnapshot Snapshot(PpapRegistrationRecord record)
|
||||
=> new(record.Version, record.Nonce, record.EncapsulationKey);
|
||||
|
||||
static void AssertSnapshot(RegistrationSnapshot expected,
|
||||
PpapRegistrationRecord actual)
|
||||
{
|
||||
Assert.Equal(expected.Version, actual.Version);
|
||||
Assert.Equal(expected.Nonce, actual.Nonce);
|
||||
Assert.Equal(expected.EncapsulationKey, actual.EncapsulationKey);
|
||||
}
|
||||
|
||||
static void AssertRotated(RegistrationSnapshot before,
|
||||
PpapRegistrationRecord after)
|
||||
{
|
||||
Assert.Equal(before.Version + 1, after.Version);
|
||||
Assert.False(before.Nonce.SequenceEqual(after.Nonce));
|
||||
Assert.False(before.EncapsulationKey.SequenceEqual(after.EncapsulationKey));
|
||||
}
|
||||
|
||||
static PpapPair CreatePair(
|
||||
AuthenticationMode mode,
|
||||
string initiatorPassword = InitiatorPassword,
|
||||
string responderPassword = ResponderPassword)
|
||||
{
|
||||
var authenticatesInitiator = mode is AuthenticationMode.InitializerIdentity
|
||||
or AuthenticationMode.DualIdentity;
|
||||
var authenticatesResponder = mode is AuthenticationMode.ResponderIdentity
|
||||
or AuthenticationMode.DualIdentity;
|
||||
|
||||
var initiatorStore = new InMemoryPpapRegistrationStore();
|
||||
var responderStore = new InMemoryPpapRegistrationStore();
|
||||
PpapLocalIdentity? initiatorLocal = null;
|
||||
PpapLocalIdentity? responderLocal = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (authenticatesInitiator)
|
||||
{
|
||||
initiatorLocal = PpapLocalIdentity.FromPassword(
|
||||
InitiatorIdentity, initiatorPassword, TestKdf);
|
||||
using var registered = PpapLocalIdentity.FromPassword(
|
||||
InitiatorIdentity, InitiatorPassword, TestKdf);
|
||||
Assert.True(responderStore.TryAdd(
|
||||
Domain,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, registered)));
|
||||
}
|
||||
|
||||
if (authenticatesResponder)
|
||||
{
|
||||
responderLocal = PpapLocalIdentity.FromPassword(
|
||||
ResponderIdentity, responderPassword, TestKdf);
|
||||
using var registered = PpapLocalIdentity.FromPassword(
|
||||
ResponderIdentity, ResponderPassword, TestKdf);
|
||||
Assert.True(initiatorStore.TryAdd(
|
||||
Domain,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, registered)));
|
||||
}
|
||||
|
||||
var initiatorProvider = new PpapAuthenticationProvider(
|
||||
initiatorLocal!, initiatorStore);
|
||||
var responderProvider = new PpapAuthenticationProvider(
|
||||
responderLocal!, responderStore);
|
||||
var initiator = Assert.IsType<PpapAuthenticationHandler>(
|
||||
initiatorProvider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = AuthenticationDirection.Initiator,
|
||||
Mode = mode,
|
||||
Domain = Domain,
|
||||
InitiatorIdentity = authenticatesInitiator ? InitiatorIdentity : null,
|
||||
ResponderIdentity = authenticatesResponder ? ResponderIdentity : null,
|
||||
HostName = ResponderIdentity,
|
||||
}));
|
||||
var responder = Assert.IsType<PpapAuthenticationHandler>(
|
||||
responderProvider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = AuthenticationDirection.Responder,
|
||||
Mode = mode,
|
||||
Domain = Domain,
|
||||
InitiatorIdentity = authenticatesInitiator ? InitiatorIdentity : null,
|
||||
ResponderIdentity = authenticatesResponder ? ResponderIdentity : null,
|
||||
HostName = ResponderIdentity,
|
||||
}));
|
||||
|
||||
return new PpapPair(
|
||||
initiatorLocal,
|
||||
responderLocal,
|
||||
initiatorStore,
|
||||
responderStore,
|
||||
initiator,
|
||||
responder);
|
||||
}
|
||||
catch
|
||||
{
|
||||
initiatorLocal?.Dispose();
|
||||
responderLocal?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static PpapPair CreateStaticPair()
|
||||
{
|
||||
PpapCryptography.GenerateKeyPair(
|
||||
out var initiatorPrivateKey,
|
||||
out var initiatorPublicKey);
|
||||
PpapCryptography.GenerateKeyPair(
|
||||
out var responderPrivateKey,
|
||||
out var responderPublicKey);
|
||||
PpapLocalIdentity? initiatorLocal = null;
|
||||
PpapLocalIdentity? responderLocal = null;
|
||||
|
||||
try
|
||||
{
|
||||
initiatorLocal = PpapLocalIdentity.FromStaticKey(
|
||||
InitiatorIdentity,
|
||||
initiatorPrivateKey);
|
||||
responderLocal = PpapLocalIdentity.FromStaticKey(
|
||||
ResponderIdentity,
|
||||
responderPrivateKey);
|
||||
var initiatorStore = new InMemoryPpapRegistrationStore();
|
||||
var responderStore = new InMemoryPpapRegistrationStore();
|
||||
Assert.True(responderStore.TryAdd(
|
||||
Domain,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, initiatorLocal)));
|
||||
Assert.True(initiatorStore.TryAdd(
|
||||
Domain,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, responderLocal)));
|
||||
|
||||
var initiatorProvider = new PpapAuthenticationProvider(
|
||||
initiatorLocal!,
|
||||
initiatorStore);
|
||||
var responderProvider = new PpapAuthenticationProvider(
|
||||
responderLocal!,
|
||||
responderStore);
|
||||
var initiator = Assert.IsType<PpapAuthenticationHandler>(
|
||||
initiatorProvider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = AuthenticationDirection.Initiator,
|
||||
Mode = AuthenticationMode.DualIdentity,
|
||||
Domain = Domain,
|
||||
InitiatorIdentity = InitiatorIdentity,
|
||||
ResponderIdentity = ResponderIdentity,
|
||||
HostName = ResponderIdentity,
|
||||
}));
|
||||
var responder = Assert.IsType<PpapAuthenticationHandler>(
|
||||
responderProvider.CreateAuthenticationHandler(new AuthenticationContext
|
||||
{
|
||||
Direction = AuthenticationDirection.Responder,
|
||||
Mode = AuthenticationMode.DualIdentity,
|
||||
Domain = Domain,
|
||||
InitiatorIdentity = InitiatorIdentity,
|
||||
ResponderIdentity = ResponderIdentity,
|
||||
HostName = ResponderIdentity,
|
||||
}));
|
||||
|
||||
return new PpapPair(
|
||||
initiatorLocal,
|
||||
responderLocal,
|
||||
initiatorStore,
|
||||
responderStore,
|
||||
initiator,
|
||||
responder);
|
||||
}
|
||||
catch
|
||||
{
|
||||
initiatorLocal?.Dispose();
|
||||
responderLocal?.Dispose();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(initiatorPrivateKey);
|
||||
PpapCryptography.Clear(initiatorPublicKey);
|
||||
PpapCryptography.Clear(responderPrivateKey);
|
||||
PpapCryptography.Clear(responderPublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
readonly record struct RegistrationSnapshot(
|
||||
long Version,
|
||||
byte[] Nonce,
|
||||
byte[] EncapsulationKey);
|
||||
|
||||
readonly record struct HandshakeResult(
|
||||
AuthenticationResult Initiator,
|
||||
AuthenticationResult Responder);
|
||||
|
||||
sealed class PpapPair : IDisposable
|
||||
{
|
||||
readonly PpapLocalIdentity? _initiatorLocal;
|
||||
readonly PpapLocalIdentity? _responderLocal;
|
||||
|
||||
public InMemoryPpapRegistrationStore InitiatorStore { get; }
|
||||
public InMemoryPpapRegistrationStore ResponderStore { get; }
|
||||
public PpapAuthenticationHandler Initiator { get; }
|
||||
public PpapAuthenticationHandler Responder { get; }
|
||||
|
||||
public PpapPair(
|
||||
PpapLocalIdentity? initiatorLocal,
|
||||
PpapLocalIdentity? responderLocal,
|
||||
InMemoryPpapRegistrationStore initiatorStore,
|
||||
InMemoryPpapRegistrationStore responderStore,
|
||||
PpapAuthenticationHandler initiator,
|
||||
PpapAuthenticationHandler responder)
|
||||
{
|
||||
_initiatorLocal = initiatorLocal;
|
||||
_responderLocal = responderLocal;
|
||||
InitiatorStore = initiatorStore;
|
||||
ResponderStore = responderStore;
|
||||
Initiator = initiator;
|
||||
Responder = responder;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Initiator.Dispose();
|
||||
Responder.Dispose();
|
||||
_initiatorLocal?.Dispose();
|
||||
_responderLocal?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class PpapRegistrationStoreTests
|
||||
{
|
||||
const string Domain = "example.test";
|
||||
const string Identity = "alice";
|
||||
|
||||
static readonly PpapKdfProfile TestKdf = new(
|
||||
PpapKdfProfile.Argon2Version13,
|
||||
memoryKiB: 8 * 1024,
|
||||
iterations: 1,
|
||||
parallelism: 1);
|
||||
|
||||
[Fact]
|
||||
public async Task TryRotate_ConcurrentCompareAndSwap_AllowsExactlyOneWinner()
|
||||
{
|
||||
var store = new InMemoryPpapRegistrationStore();
|
||||
var current = PpapRegistrationRecord.FromPassword(
|
||||
Domain, Identity, "correct horse battery staple", kdfProfile: TestKdf);
|
||||
Assert.True(store.TryAdd(Domain, current));
|
||||
|
||||
var candidate = Replacement(current, marker: 1);
|
||||
var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var attempts = Enumerable.Range(0, 16).Select(async _ =>
|
||||
{
|
||||
await start.Task;
|
||||
return store.TryRotate(Domain, Identity, current.Version, candidate);
|
||||
}).ToArray();
|
||||
|
||||
start.SetResult();
|
||||
var results = await Task.WhenAll(attempts);
|
||||
|
||||
Assert.Single(results, won => won);
|
||||
var stored = Assert.IsType<PpapRegistrationRecord>(store.Get(Domain, Identity));
|
||||
|
||||
Assert.Same(candidate, stored);
|
||||
Assert.Equal(current.Version + 1, stored.Version);
|
||||
Assert.Equal(candidate.Nonce, stored.Nonce);
|
||||
Assert.Equal(candidate.EncapsulationKey, stored.EncapsulationKey);
|
||||
Assert.False(current.Nonce.SequenceEqual(stored.Nonce));
|
||||
Assert.False(current.EncapsulationKey.SequenceEqual(stored.EncapsulationKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRotate_StaleVersionFailsWithoutChangingRecord()
|
||||
{
|
||||
var store = new InMemoryPpapRegistrationStore();
|
||||
var current = PpapRegistrationRecord.FromPassword(
|
||||
Domain, Identity, "correct horse battery staple", kdfProfile: TestKdf);
|
||||
var replacement = Replacement(current, marker: 99);
|
||||
Assert.True(store.TryAdd(Domain, current));
|
||||
|
||||
Assert.False(store.TryRotate(Domain, Identity, current.Version - 1, replacement));
|
||||
|
||||
var stored = Assert.IsType<PpapRegistrationRecord>(store.Get(Domain, Identity));
|
||||
Assert.Same(current, stored);
|
||||
Assert.Equal(current.Version, stored.Version);
|
||||
Assert.Equal(current.Nonce, stored.Nonce);
|
||||
Assert.Equal(current.EncapsulationKey, stored.EncapsulationKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRotate_RejectsVersionBumpWithoutFreshNonceAndKey()
|
||||
{
|
||||
var store = new InMemoryPpapRegistrationStore();
|
||||
var current = PpapRegistrationRecord.FromPassword(
|
||||
Domain, Identity, "correct horse battery staple", kdfProfile: TestKdf);
|
||||
var noOp = new PpapRegistrationRecord(
|
||||
current.Version + 1,
|
||||
current.Identity,
|
||||
current.Kind,
|
||||
current.Nonce,
|
||||
current.EncapsulationKey,
|
||||
current.KdfProfile);
|
||||
Assert.True(store.TryAdd(Domain, current));
|
||||
|
||||
Assert.False(store.TryRotate(Domain, Identity, current.Version, noOp));
|
||||
Assert.Same(current, store.Get(Domain, Identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRotate_RejectsChangedKdfProfile()
|
||||
{
|
||||
var store = new InMemoryPpapRegistrationStore();
|
||||
var current = PpapRegistrationRecord.FromPassword(
|
||||
Domain, Identity, "correct horse battery staple", kdfProfile: TestKdf);
|
||||
var changedKdf = new PpapKdfProfile(
|
||||
PpapKdfProfile.Argon2Version13,
|
||||
memoryKiB: 8 * 1024,
|
||||
iterations: 2,
|
||||
parallelism: 1);
|
||||
var replacement = Replacement(
|
||||
current,
|
||||
marker: 7,
|
||||
kdfProfile: changedKdf);
|
||||
Assert.True(store.TryAdd(Domain, current));
|
||||
|
||||
Assert.False(store.TryRotate(
|
||||
Domain,
|
||||
Identity,
|
||||
current.Version,
|
||||
replacement));
|
||||
Assert.Same(current, store.Get(Domain, Identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PasswordRegistration_IsDomainSeparated()
|
||||
{
|
||||
var nonce = Enumerable.Range(1, PpapProtocol.RegistrationNonceLength)
|
||||
.Select(value => (byte)value)
|
||||
.ToArray();
|
||||
|
||||
var first = PpapRegistrationRecord.FromPassword(
|
||||
"first.example",
|
||||
Identity,
|
||||
"correct horse battery staple",
|
||||
nonce: nonce,
|
||||
kdfProfile: TestKdf);
|
||||
var second = PpapRegistrationRecord.FromPassword(
|
||||
"second.example",
|
||||
Identity,
|
||||
"correct horse battery staple",
|
||||
nonce: nonce,
|
||||
kdfProfile: TestKdf);
|
||||
|
||||
Assert.Equal(first.Nonce, second.Nonce);
|
||||
Assert.False(first.EncapsulationKey.SequenceEqual(second.EncapsulationKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegistrationRecord_DoesNotExposeMutableKeyMaterial()
|
||||
{
|
||||
var record = PpapRegistrationRecord.FromPassword(
|
||||
Domain,
|
||||
Identity,
|
||||
"correct horse battery staple",
|
||||
kdfProfile: TestKdf);
|
||||
var originalNonce = record.Nonce;
|
||||
var originalKey = record.EncapsulationKey;
|
||||
|
||||
var exposedNonce = record.Nonce;
|
||||
var exposedKey = record.EncapsulationKey;
|
||||
exposedNonce[0] ^= 0xFF;
|
||||
exposedKey[0] ^= 0xFF;
|
||||
|
||||
Assert.Equal(originalNonce, record.Nonce);
|
||||
Assert.Equal(originalKey, record.EncapsulationKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportStaticPrivateKey_RoundTripsRegistrationAndReturnsDefensiveCopies()
|
||||
{
|
||||
using var original = PpapLocalIdentity.CreateStatic(Identity);
|
||||
var originalRegistration = PpapRegistrationRecord.FromLocalIdentity(
|
||||
Domain,
|
||||
original);
|
||||
|
||||
var firstExport = original.ExportStaticPrivateKey();
|
||||
var secondExport = original.ExportStaticPrivateKey();
|
||||
Assert.NotSame(firstExport, secondExport);
|
||||
Assert.Equal(firstExport, secondExport);
|
||||
|
||||
using var imported = PpapLocalIdentity.FromStaticKey(Identity, firstExport);
|
||||
var importedRegistration = PpapRegistrationRecord.FromLocalIdentity(
|
||||
Domain,
|
||||
imported);
|
||||
|
||||
Assert.Equal(originalRegistration.Identity, importedRegistration.Identity);
|
||||
Assert.Equal(originalRegistration.Kind, importedRegistration.Kind);
|
||||
Assert.Equal(originalRegistration.Nonce, importedRegistration.Nonce);
|
||||
Assert.Equal(
|
||||
originalRegistration.EncapsulationKey,
|
||||
importedRegistration.EncapsulationKey);
|
||||
|
||||
firstExport[0] ^= 0xFF;
|
||||
secondExport[1] ^= 0xFF;
|
||||
|
||||
Assert.Equal(
|
||||
originalRegistration.EncapsulationKey,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, original).EncapsulationKey);
|
||||
Assert.Equal(
|
||||
importedRegistration.EncapsulationKey,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, imported).EncapsulationKey);
|
||||
|
||||
var importedExport = imported.ExportStaticPrivateKey();
|
||||
Assert.NotSame(firstExport, importedExport);
|
||||
importedExport[2] ^= 0xFF;
|
||||
Assert.Equal(
|
||||
importedRegistration.EncapsulationKey,
|
||||
PpapRegistrationRecord.FromLocalIdentity(Domain, imported).EncapsulationKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PasswordDerivationLimiter_RejectsImmediatelyWhenSaturated()
|
||||
{
|
||||
var limiter = new PpapPasswordDerivationLimiter(1);
|
||||
using var identity = PpapLocalIdentity.FromPassword(
|
||||
Identity,
|
||||
"correct horse battery staple",
|
||||
TestKdf);
|
||||
var provider = new PpapAuthenticationProvider(
|
||||
identity,
|
||||
passwordDerivationLimiter: limiter);
|
||||
var nonce = Enumerable.Range(0, PpapProtocol.RegistrationNonceLength)
|
||||
.Select(value => (byte)(value + 1))
|
||||
.ToArray();
|
||||
|
||||
using var heldSlot = limiter.TryAcquire(postAuthentication: false);
|
||||
Assert.NotNull(heldSlot);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
provider.DerivePrivateKey(identity, Domain, nonce, TestKdf));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PasswordDerivationLimiter_ReservesPostAuthenticationCapacityAndReleases()
|
||||
{
|
||||
var limiter = new PpapPasswordDerivationLimiter(
|
||||
maximumConcurrency: 2,
|
||||
reservedPostAuthenticationSlots: 1);
|
||||
|
||||
var preAuthentication = limiter.TryAcquire(postAuthentication: false);
|
||||
Assert.NotNull(preAuthentication);
|
||||
Assert.Null(limiter.TryAcquire(postAuthentication: false));
|
||||
|
||||
using (var postAuthentication = limiter.TryAcquire(postAuthentication: true))
|
||||
Assert.NotNull(postAuthentication);
|
||||
|
||||
Assert.Null(limiter.TryAcquire(postAuthentication: false));
|
||||
preAuthentication.Dispose();
|
||||
using var reacquired = limiter.TryAcquire(postAuthentication: false);
|
||||
Assert.NotNull(reacquired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PasswordDerivationLimiter_AllowsPostAuthenticationDerivationInReservedSlot()
|
||||
{
|
||||
var limiter = new PpapPasswordDerivationLimiter(
|
||||
maximumConcurrency: 2,
|
||||
reservedPostAuthenticationSlots: 1);
|
||||
using var identity = PpapLocalIdentity.FromPassword(
|
||||
Identity,
|
||||
"correct horse battery staple",
|
||||
TestKdf);
|
||||
var provider = new PpapAuthenticationProvider(
|
||||
identity,
|
||||
passwordDerivationLimiter: limiter);
|
||||
var nonce = Enumerable.Range(0, PpapProtocol.RegistrationNonceLength)
|
||||
.Select(value => (byte)(value + 1))
|
||||
.ToArray();
|
||||
|
||||
using var occupiedPreAuthenticationSlot =
|
||||
limiter.TryAcquire(postAuthentication: false);
|
||||
Assert.NotNull(occupiedPreAuthenticationSlot);
|
||||
var privateKey = provider.DerivePrivateKey(
|
||||
identity,
|
||||
Domain,
|
||||
nonce,
|
||||
TestKdf,
|
||||
postAuthentication: true);
|
||||
try
|
||||
{
|
||||
Assert.Equal(PpapProtocol.PrivateKeyLength, privateKey.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
static PpapRegistrationRecord Replacement(
|
||||
PpapRegistrationRecord current,
|
||||
int marker,
|
||||
PpapKdfProfile? kdfProfile = null)
|
||||
{
|
||||
var nonce = current.Nonce;
|
||||
nonce[0] ^= (byte)marker;
|
||||
nonce[^1] ^= (byte)(marker * 17);
|
||||
PpapCryptography.GenerateKeyPair(out var privateKey, out var publicKey);
|
||||
try
|
||||
{
|
||||
return new PpapRegistrationRecord(
|
||||
current.Version + 1,
|
||||
current.Identity,
|
||||
current.Kind,
|
||||
nonce,
|
||||
publicKey,
|
||||
kdfProfile ?? current.KdfProfile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(privateKey);
|
||||
PpapCryptography.Clear(publicKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user