From c2a63c6fe1863d5ed8d4333a962ac350dae323ba Mon Sep 17 00:00:00 2001 From: neckfire Date: Fri, 17 Jul 2026 00:49:38 +0200 Subject: [PATCH] feat(netcode): chiffrement AES-256-ECB conforme FAESHandlerComponent d'UE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recycle a confirmé : 'basic aes', clé server-side déductible client, by-the-book reimpl, pas de handshake DTLS. Remplace le GCM par AES-256-ECB + PKCS#7 + flag bit + termination bit (mirror du source UE 4.27). Flux : SetKey -> EncryptionAck clair -> Enable -> Challenge chiffré. Supprime AesGcmHandlerComponent. --- .../Net/AesGcmHandlerComponent.cs | 130 -------------- .../Net/AesHandlerComponent.cs | 159 ++++++++++++++++++ src/Prospect.Unreal/Net/UNetConnection.cs | 11 +- src/Prospect.Unreal/Runtime/UWorld.cs | 20 ++- 4 files changed, 177 insertions(+), 143 deletions(-) delete mode 100644 src/Prospect.Unreal/Net/AesGcmHandlerComponent.cs create mode 100644 src/Prospect.Unreal/Net/AesHandlerComponent.cs diff --git a/src/Prospect.Unreal/Net/AesGcmHandlerComponent.cs b/src/Prospect.Unreal/Net/AesGcmHandlerComponent.cs deleted file mode 100644 index 3662f37..0000000 --- a/src/Prospect.Unreal/Net/AesGcmHandlerComponent.cs +++ /dev/null @@ -1,130 +0,0 @@ -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/AesHandlerComponent.cs b/src/Prospect.Unreal/Net/AesHandlerComponent.cs new file mode 100644 index 0000000..27ba292 --- /dev/null +++ b/src/Prospect.Unreal/Net/AesHandlerComponent.cs @@ -0,0 +1,159 @@ +using System.Security.Cryptography; +using Prospect.Unreal.Serialization; +using Serilog; + +namespace Prospect.Unreal.Net; + +/// +/// Chiffrement réseau conforme au FAESHandlerComponent d'UE 4.27 : AES-256-ECB, +/// padding PKCS#7 (défaut OpenSSL), clé 32 o, PAS d'IV. Format sur le fil (mirror +/// du source UE) : +/// [1 bit flag "encryption enabled"][ciphertext bit-packé, décalé de 1 bit] +/// Avant chiffrement, un "termination bit" (=1) est écrit sur le plaintext pour +/// préserver le compte de bits exact (le dernier octet déchiffré porte ce bit haut). +/// +/// Le handler ne s'active qu'après EnableEncryption (déclenché à réception du +/// NMT_Hello chiffré côté serveur) ; avant, passthrough (pas de flag). +/// +public sealed class AesHandlerComponent : HandlerComponent +{ + private static readonly ILogger Logger = Log.ForContext(); + + private const int KeySize = 32; + private const int BlockSize = 16; + + private byte[]? _key; + + public AesHandlerComponent(PacketHandler handler) : base(handler, nameof(AesHandlerComponent)) + { + } + + public override void Initialize() + { + SetActive(false); + Initialized(); + } + + public override bool IsValid() => true; + + /// SetEncryptionData : pose la clé (32 o) sans activer le chiffrement sortant. + public void SetKey(byte[] key) + { + if (key.Length != KeySize) + { + Logger.Warning("[AES] Clé de taille {N} (attendu {K}) — ignorée", key.Length, KeySize); + return; + } + _key = key; + Logger.Information("[AES] Clé posée ({N} o)", key.Length); + } + + /// EnableEncryption : à partir d'ici la sortie est chiffrée. À appeler APRÈS SetKey + /// et APRÈS avoir envoyé le NMT_EncryptionAck en clair. + public void Enable() + { + SetActive(true); + Logger.Information("[AES] Chiffrement AES-256-ECB activé"); + } + + public override void Incoming(FBitReader packet) + { + if (!IsActive()) + { + return; // pas encore de flag avant activation + } + + var flag = packet.ReadBit(); + if (!flag) + { + return; // paquet en clair (flag=0) — passthrough du reste + } + if (_key == null || packet.GetBytesLeft() <= 0) + { + return; + } + + var n = packet.GetBytesLeft(); + var cipher = new byte[n]; // zéro-init (dernier octet à 0 comme UE) + var bitsLeft = packet.GetBitsLeft(); + unsafe + { + fixed (byte* p = cipher) + { + packet.SerializeBits(p, bitsLeft); // relit tous les bits restants -> ré-aligne le ciphertext + } + } + + byte[] plain; + try + { + using var aes = Aes.Create(); + aes.Key = _key; + plain = aes.DecryptEcb(cipher, PaddingMode.PKCS7); + } + catch (CryptographicException ex) + { + Logger.Warning("[AES] Déchiffrement échoué ({M}) — {N}o head={H}", ex.Message, n, + Convert.ToHexString(cipher.AsSpan(0, Math.Min(n, 16)))); + packet.SetError(); + return; + } + + // Taille réelle en bits = position du bit à 1 le plus haut du dernier octet (termination bit). + if (plain.Length == 0) + { + packet.SetError(); + return; + } + var last = plain[^1]; + var bits = plain.Length * 8 - 1; + while ((last & 0x80) == 0 && bits >= 0) + { + last <<= 1; + bits--; + } + if (bits < 0) + { + packet.SetError(); // dernier octet nul -> malformé + return; + } + Logger.Information("[AES] Incoming déchiffré {N}o -> {B} bits", n, bits); + packet.SetData(plain, bits); + } + + public override void Outgoing(ref FBitWriter packet, FOutPacketTraits traits) + { + if (!IsActive()) + { + return; // passthrough (pas de flag avant activation) + } + + // Termination bit -> le plaintext est byte-aligné (FBitWriter complète en zéros). + packet.WriteBit(true); + var plainLen = (int)packet.GetNumBytes(); + var plaintext = packet.GetData(); + + byte[] cipher; + using (var aes = Aes.Create()) + { + aes.Key = _key!; + cipher = aes.EncryptEcb(plaintext.AsSpan(0, plainLen), PaddingMode.PKCS7); + } + + var newPacket = new FBitWriter((long)cipher.Length * 8 + 2, true, false); + newPacket.WriteBit(true); // flag = encryption enabled + unsafe + { + fixed (byte* p = cipher) + { + newPacket.Serialize(p, cipher.Length); // ciphertext décalé de 1 bit derrière le flag + } + } + packet = newPacket; + } + + // 1 (flag) + 1 (termination) + 7 (bourrage octet) + 128 (bloc PKCS#7) — cf. UE. + public override int GetReservedPacketBits() => 2 + 7 + BlockSize * 8; + + public bool IsEnabled => IsActive(); +} diff --git a/src/Prospect.Unreal/Net/UNetConnection.cs b/src/Prospect.Unreal/Net/UNetConnection.cs index 30d6c35..9bbf7b8 100644 --- a/src/Prospect.Unreal/Net/UNetConnection.cs +++ b/src/Prospect.Unreal/Net/UNetConnection.cs @@ -226,8 +226,8 @@ 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; } + /// Composant de chiffrement AES-256-ECB (conforme FAESHandlerComponent d'UE), keyé par la PSK. + public AesHandlerComponent? AesComponent { get; private set; } /// /// Net id of remote player on this connection. Only valid on client connections (server side). @@ -1181,9 +1181,10 @@ 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(); + // Chiffrement AES-256-ECB conforme FAESHandlerComponent d'UE (keyé par la PSK). + // Ajouté EN DERNIER -> couche la plus externe (chiffre tout ce qui précède). + // Inactif jusqu'à EnableEncryption (déclenché au NMT_Hello chiffré, cf. UWorld). + AesComponent = (AesHandlerComponent) Handler.AddHandler(); Handler.InitializeComponents(); diff --git a/src/Prospect.Unreal/Runtime/UWorld.cs b/src/Prospect.Unreal/Runtime/UWorld.cs index 91f91e1..1efaa51 100644 --- a/src/Prospect.Unreal/Runtime/UWorld.cs +++ b/src/Prospect.Unreal/Runtime/UWorld.cs @@ -324,16 +324,20 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable // Le serveur passe en mode ACCEPT (attend le ClientHello, ne parle pas // en premier) et NE DOIT PAS envoyer le Challenge maintenant : il est // différé jusqu'à la fin du handshake DTLS. - var pskKey = DtlsPsks.Value.Get(encryptionToken); - if (pskKey != null && connection.DtlsComponent != null) + // Flux d'encryption UE (AES-256-ECB, cf. FAESHandlerComponent) : + // SetEncryptionData(clé) -> NMT_EncryptionAck EN CLAIR -> EnableEncryption + // (à partir d'ici la sortie serveur est chiffrée) -> Challenge chiffré. + // Le client déduit la même clé du token, active son chiffrement, déchiffre + // le Challenge et répond (Login) chiffré. Clé = notre PSK (server-side). + var aesKey = DtlsPsks.Value.Get(encryptionToken); + if (aesKey != null && connection.AesComponent != null) { - Logger.Information("DTLS: identité {Token} -> EncryptionAck (clair) + accept (attente ClientHello, PAS de Challenge)", encryptionToken); + Logger.Information("AES: identité {Token} -> SetKey + EncryptionAck (clair) + Enable + Challenge (chiffré)", encryptionToken); + connection.AesComponent.SetKey(aesKey); NMT_EncryptionAck.Send(connection); - connection.FlushNet(); - connection.DtlsComponent.BeginHandshake( - DtlsPsks.Value, encryptionToken, - rec => connection.LowLevelSend(rec, rec.Length * 8, new FOutPacketTraits())); - // Challenge différé : envoyé une fois le handshake DTLS terminé. + connection.FlushNet(); // ack EN CLAIR (avant Enable) + connection.AesComponent.Enable(); // sortie chiffrée à partir d'ici + connection.SendChallengeControlMessage(); // Challenge chiffré } else {