feat(netcode): chiffrement AES-256-ECB conforme FAESHandlerComponent d'UE
Build Game Server / build (push) Successful in 23s
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:
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user