feat(netcode): chiffrement AES-256-GCM keyé par la PSK (piste retenue)
Build Game Server / build (push) Successful in 21s

Le client n'entame pas de handshake DTLS et ferme si nos paquets post-ack sont en
clair. AesGcmHandlerComponent (System.Security.Cryptography.AesGcm, [IV12][ct][tag16]),
activé après l'EncryptionAck : ack en clair -> activation -> Challenge chiffré.
This commit is contained in:
2026-07-16 23:47:00 +02:00
parent a3dc1e8543
commit 5c81fba79c
3 changed files with 154 additions and 10 deletions
@@ -0,0 +1,130 @@
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;
}
}
@@ -226,6 +226,9 @@ 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>
public AesGcmHandlerComponent? 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).
/// </summary> /// </summary>
@@ -1178,6 +1181,9 @@ 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
// NMT_Hello portant un EncryptionToken (cf. UWorld).
AesComponent = (AesGcmHandlerComponent) Handler.AddHandler<AesGcmHandlerComponent>();
Handler.InitializeComponents(); Handler.InitializeComponents();
+18 -10
View File
@@ -314,16 +314,24 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
// Chiffrement DTLS-PSK requis par le client. Si on connaît la // Chiffrement DTLS-PSK requis par le client. Si on connaît la
// PSK de cette identité (user_id), on répond NMT_EncryptionAck et // PSK de cette identité (user_id), on répond NMT_EncryptionAck et
// on établit le DTLS côté serveur (BouncyCastle). Cf. DTLSHandlerComponent. // on établit le DTLS côté serveur (BouncyCastle). Cf. DTLSHandlerComponent.
// OBSERVÉ EN LIVE : après NMT_EncryptionAck, le client envoie son // PISTE AES-GCM (test live précédent : le client ferme quand nos
// NMT_Login EN CLAIR (paquet UE de 18o, jamais de ClientHello DTLS // paquets post-ack sont EN CLAIR → il attend une voie serveur chiffrée,
// 16fefd). Hypothèse : l'EncryptionAck suffit à satisfaire son check // sans handshake DTLS). On envoie l'EncryptionAck EN CLAIR, on active
// « chiffrement acquitté » et le canal de contrôle reste en clair. // AES-256-GCM keyé par la PSK, puis le Challenge part CHIFFRÉ.
// => on ACQUITTE mais on N'ACTIVE PAS DTLS (sinon on mange le Login), var aesKey = DtlsPsks.Value.Get(encryptionToken);
// et on laisse le flux login se poursuivre normalement. if (aesKey != null && connection.AesComponent != null)
Logger.Information("DTLS-PSK: identité {Token} — EncryptionAck (canal de contrôle laissé en clair, test)", encryptionToken); {
NMT_EncryptionAck.Send(connection); Logger.Information("AES-GCM: identité {Token} connue -> EncryptionAck (clair) + activation + Challenge (chiffré)", encryptionToken);
connection.FlushNet(); NMT_EncryptionAck.Send(connection);
connection.SendChallengeControlMessage(); 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; break;