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); } }