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