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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user