diff --git a/src/Prospect.Unreal/Net/AesGcmHandlerComponent.cs b/src/Prospect.Unreal/Net/AesGcmHandlerComponent.cs new file mode 100644 index 0000000..3662f37 --- /dev/null +++ b/src/Prospect.Unreal/Net/AesGcmHandlerComponent.cs @@ -0,0 +1,130 @@ +using System.Security.Cryptography; +using Prospect.Unreal.Serialization; +using Serilog; + +namespace Prospect.Unreal.Net; + +/// +/// Chiffrement AES-256-GCM keyé directement par la PSK (pendant de l'AESGCMHandlerComponent +/// d'UE / flux SetEncryptionData+EnableEncryption). Hypothèse retenue après test live : le +/// client n'entame pas de handshake DTLS ; après NMT_EncryptionAck il attend une voie serveur +/// chiffrée. Pas de handshake → la PSK 32 o EST la clé AES. +/// +/// Format paquet UE : [IV 12o][ciphertext][tag 16o]. +/// +/// ⚠️ Première implémentation — l'alignement bit exact du framing UE est à valider EN LIVE. +/// +public sealed class AesGcmHandlerComponent : HandlerComponent +{ + private static readonly ILogger Logger = Log.ForContext(); + + private const int IvSize = 12; + private const int TagSize = 16; + + private AesGcm? _aes; + + public AesGcmHandlerComponent(PacketHandler handler) : base(handler, nameof(AesGcmHandlerComponent)) + { + } + + public override void Initialize() + { + SetActive(false); + Initialized(); + } + + public override bool IsValid() => true; + + /// Active le chiffrement avec la clé (PSK 32 o). À appeler APRÈS avoir envoyé + /// l'EncryptionAck en clair. + public void Activate(byte[] key) + { + _aes = new AesGcm(key, TagSize); + SetActive(true); + Logger.Information("[AES] Chiffrement AES-256-GCM activé (clé {N} o)", key.Length); + } + + public override void Incoming(FBitReader packet) + { + if (!IsActive() || _aes == null) + { + return; + } + + var buf = ReadAlignedBytes(packet); + if (buf.Length < IvSize + TagSize) + { + Logger.Warning("[AES] Incoming trop court ({N} o) — ignoré", buf.Length); + packet.SetData(Array.Empty(), 0); + return; + } + + var iv = buf.AsSpan(0, IvSize); + var tag = buf.AsSpan(buf.Length - TagSize, TagSize); + var ct = buf.AsSpan(IvSize, buf.Length - IvSize - TagSize); + var plain = new byte[ct.Length]; + try + { + _aes.Decrypt(iv, ct, tag, plain); + Logger.Information("[AES] Incoming déchiffré {N}o -> {P}o head={H}", buf.Length, plain.Length, + Convert.ToHexString(plain.AsSpan(0, Math.Min(plain.Length, 12)))); + packet.SetData(plain, (long)plain.Length * 8); + } + catch (CryptographicException ex) + { + Logger.Warning("[AES] Déchiffrement échoué ({M}) — paquet {N}o head={H}", ex.Message, buf.Length, + Convert.ToHexString(buf.AsSpan(0, Math.Min(buf.Length, 16)))); + packet.SetError(); + } + } + + public override void Outgoing(ref FBitWriter packet, FOutPacketTraits traits) + { + if (!IsActive() || _aes == null) + { + return; + } + + var plain = packet.GetData(); + var nbytes = (int)packet.GetNumBytes(); + + var iv = new byte[IvSize]; + RandomNumberGenerator.Fill(iv); + var ct = new byte[nbytes]; + var tag = new byte[TagSize]; + _aes.Encrypt(iv, plain.AsSpan(0, nbytes), ct, tag); + + var outBuf = new byte[IvSize + nbytes + TagSize]; + iv.CopyTo(outBuf, 0); + ct.CopyTo(outBuf, IvSize); + tag.CopyTo(outBuf, IvSize + nbytes); + + var newPacket = new FBitWriter((long)outBuf.Length * 8 + 1, true, false); + newPacket.SerializeBits(outBuf, outBuf.Length * 8); + packet = newPacket; + Logger.Information("[AES] Outgoing chiffré {P}o -> {N}o", nbytes, outBuf.Length); + } + + public override int GetReservedPacketBits() => (IvSize + TagSize) * 8; + + public bool IsEnabled => IsActive(); + + private static byte[] ReadAlignedBytes(FBitReader packet) + { + var bytes = packet.GetBytesLeft(); + if (bytes <= 0) + { + return Array.Empty(); + } + + var buf = new byte[bytes]; + unsafe + { + fixed (byte* p = buf) + { + packet.Serialize(p, bytes); + } + } + return buf; + } +} diff --git a/src/Prospect.Unreal/Net/UNetConnection.cs b/src/Prospect.Unreal/Net/UNetConnection.cs index 68ee934..30d6c35 100644 --- a/src/Prospect.Unreal/Net/UNetConnection.cs +++ b/src/Prospect.Unreal/Net/UNetConnection.cs @@ -226,6 +226,9 @@ public abstract class UNetConnection : UPlayer /// public DTLSHandlerComponent? DtlsComponent { get; private set; } + /// Composant de chiffrement AES-256-GCM (keyé par la PSK), activé au NMT_Hello chiffré. + public AesGcmHandlerComponent? AesComponent { get; private set; } + /// /// Net id of remote player on this connection. Only valid on client connections (server side). /// @@ -1178,6 +1181,9 @@ public abstract class UNetConnection : UPlayer // NB ordre : la pile client est [DTLS, Stateless] — l'ordre exact ici est à // valider en live contre le vrai client. DtlsComponent = (DTLSHandlerComponent) Handler.AddHandler(); + // Chiffrement AES-256-GCM keyé par la PSK (piste retenue). Inactif jusqu'au + // NMT_Hello portant un EncryptionToken (cf. UWorld). + AesComponent = (AesGcmHandlerComponent) Handler.AddHandler(); Handler.InitializeComponents(); diff --git a/src/Prospect.Unreal/Runtime/UWorld.cs b/src/Prospect.Unreal/Runtime/UWorld.cs index 381a0cd..38e54b1 100644 --- a/src/Prospect.Unreal/Runtime/UWorld.cs +++ b/src/Prospect.Unreal/Runtime/UWorld.cs @@ -314,16 +314,24 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable // Chiffrement DTLS-PSK requis par le client. Si on connaît la // PSK de cette identité (user_id), on répond NMT_EncryptionAck et // on établit le DTLS côté serveur (BouncyCastle). Cf. DTLSHandlerComponent. - // OBSERVÉ EN LIVE : après NMT_EncryptionAck, le client envoie son - // NMT_Login EN CLAIR (paquet UE de 18o, jamais de ClientHello DTLS - // 16fefd). Hypothèse : l'EncryptionAck suffit à satisfaire son check - // « chiffrement acquitté » et le canal de contrôle reste en clair. - // => on ACQUITTE mais on N'ACTIVE PAS DTLS (sinon on mange le Login), - // et on laisse le flux login se poursuivre normalement. - Logger.Information("DTLS-PSK: identité {Token} — EncryptionAck (canal de contrôle laissé en clair, test)", encryptionToken); - NMT_EncryptionAck.Send(connection); - connection.FlushNet(); - connection.SendChallengeControlMessage(); + // PISTE AES-GCM (test live précédent : le client ferme quand nos + // paquets post-ack sont EN CLAIR → il attend une voie serveur chiffrée, + // sans handshake DTLS). On envoie l'EncryptionAck EN CLAIR, on active + // AES-256-GCM keyé par la PSK, puis le Challenge part CHIFFRÉ. + var aesKey = DtlsPsks.Value.Get(encryptionToken); + if (aesKey != null && connection.AesComponent != null) + { + Logger.Information("AES-GCM: identité {Token} connue -> EncryptionAck (clair) + activation + Challenge (chiffré)", encryptionToken); + NMT_EncryptionAck.Send(connection); + connection.FlushNet(); // ack en clair (AES pas encore actif) + connection.AesComponent.Activate(aesKey); // à partir d'ici, sortie chiffrée + connection.SendChallengeControlMessage(); // Challenge chiffré + } + else + { + Logger.Warning("Aucune PSK pour {Token} (PROSPECT_DTLS_PSKS) — challenge en clair, le client fermera", encryptionToken); + connection.SendChallengeControlMessage(); + } } } break;