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;