feat(netcode): chiffrement AES-256-ECB conforme FAESHandlerComponent d'UE
Build Game Server / build (push) Successful in 23s

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.
This commit is contained in:
2026-07-17 00:49:38 +02:00
parent 6b3a4057be
commit c2a63c6fe1
4 changed files with 177 additions and 143 deletions
@@ -1,130 +0,0 @@
using System.Security.Cryptography;
using Prospect.Unreal.Serialization;
using Serilog;
namespace Prospect.Unreal.Net;
/// <summary>
/// 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.
/// </summary>
public sealed class AesGcmHandlerComponent : HandlerComponent
{
private static readonly ILogger Logger = Log.ForContext<AesGcmHandlerComponent>();
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;
/// <summary>Active le chiffrement avec la clé (PSK 32 o). À appeler APRÈS avoir envoyé
/// l'EncryptionAck en clair.</summary>
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<byte>(), 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<byte>();
}
var buf = new byte[bytes];
unsafe
{
fixed (byte* p = buf)
{
packet.Serialize(p, bytes);
}
}
return buf;
}
}
@@ -0,0 +1,159 @@
using System.Security.Cryptography;
using Prospect.Unreal.Serialization;
using Serilog;
namespace Prospect.Unreal.Net;
/// <summary>
/// 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).
/// </summary>
public sealed class AesHandlerComponent : HandlerComponent
{
private static readonly ILogger Logger = Log.ForContext<AesHandlerComponent>();
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;
/// <summary>SetEncryptionData : pose la clé (32 o) sans activer le chiffrement sortant.</summary>
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);
}
/// <summary>EnableEncryption : à partir d'ici la sortie est chiffrée. À appeler APRÈS SetKey
/// et APRÈS avoir envoyé le NMT_EncryptionAck en clair.</summary>
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();
}
+6 -5
View File
@@ -226,8 +226,8 @@ public abstract class UNetConnection : UPlayer
/// </summary> /// </summary>
public DTLSHandlerComponent? DtlsComponent { get; private set; } public DTLSHandlerComponent? DtlsComponent { get; private set; }
/// <summary>Composant de chiffrement AES-256-GCM (keyé par la PSK), activé au NMT_Hello chiffré.</summary> /// <summary>Composant de chiffrement AES-256-ECB (conforme FAESHandlerComponent d'UE), keyé par la PSK.</summary>
public AesGcmHandlerComponent? AesComponent { get; private set; } public AesHandlerComponent? AesComponent { get; private set; }
/// <summary> /// <summary>
/// Net id of remote player on this connection. Only valid on client connections (server side). /// 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 à // NB ordre : la pile client est [DTLS, Stateless] — l'ordre exact ici est à
// valider en live contre le vrai client. // valider en live contre le vrai client.
DtlsComponent = (DTLSHandlerComponent) Handler.AddHandler<DTLSHandlerComponent>(); DtlsComponent = (DTLSHandlerComponent) Handler.AddHandler<DTLSHandlerComponent>();
// Chiffrement AES-256-GCM keyé par la PSK (piste retenue). Inactif jusqu'au // Chiffrement AES-256-ECB conforme FAESHandlerComponent d'UE (keyé par la PSK).
// NMT_Hello portant un EncryptionToken (cf. UWorld). // Ajouté EN DERNIER -> couche la plus externe (chiffre tout ce qui précède).
AesComponent = (AesGcmHandlerComponent) Handler.AddHandler<AesGcmHandlerComponent>(); // Inactif jusqu'à EnableEncryption (déclenché au NMT_Hello chiffré, cf. UWorld).
AesComponent = (AesHandlerComponent) Handler.AddHandler<AesHandlerComponent>();
Handler.InitializeComponents(); Handler.InitializeComponents();
+12 -8
View File
@@ -324,16 +324,20 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
// Le serveur passe en mode ACCEPT (attend le ClientHello, ne parle pas // Le serveur passe en mode ACCEPT (attend le ClientHello, ne parle pas
// en premier) et NE DOIT PAS envoyer le Challenge maintenant : il est // en premier) et NE DOIT PAS envoyer le Challenge maintenant : il est
// différé jusqu'à la fin du handshake DTLS. // différé jusqu'à la fin du handshake DTLS.
var pskKey = DtlsPsks.Value.Get(encryptionToken); // Flux d'encryption UE (AES-256-ECB, cf. FAESHandlerComponent) :
if (pskKey != null && connection.DtlsComponent != null) // 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); NMT_EncryptionAck.Send(connection);
connection.FlushNet(); connection.FlushNet(); // ack EN CLAIR (avant Enable)
connection.DtlsComponent.BeginHandshake( connection.AesComponent.Enable(); // sortie chiffrée à partir d'ici
DtlsPsks.Value, encryptionToken, connection.SendChallengeControlMessage(); // Challenge chiffré
rec => connection.LowLevelSend(rec, rec.Length * 8, new FOutPacketTraits()));
// Challenge différé : envoyé une fois le handshake DTLS terminé.
} }
else else
{ {