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
{