diff --git a/src/Prospect.Unreal/Net/DTLSHandlerComponent.cs b/src/Prospect.Unreal/Net/DTLSHandlerComponent.cs
new file mode 100644
index 0000000..71750ad
--- /dev/null
+++ b/src/Prospect.Unreal/Net/DTLSHandlerComponent.cs
@@ -0,0 +1,159 @@
+using Org.BouncyCastle.Tls;
+using Prospect.Unreal.Net.Dtls;
+using Prospect.Unreal.Serialization;
+using Serilog;
+
+namespace Prospect.Unreal.Net;
+
+///
+/// Composant de chiffrement DTLS-PSK côté serveur, pendant du DTLSHandlerComponent
+/// d'UE (la pile client est [DTLSHandlerComponent, StatelessConnectHandlerComponent]).
+///
+/// Le client exige le chiffrement : il annonce son identité PSK (= user_id) dans
+/// NMT_Hello, et on répond NMT_EncryptionAck puis on établit un DTLS-PSK. Ici on
+/// implémente le côté serveur (BouncyCastle) ; la clé provient du DtlsPskStore.
+///
+/// ⚠️ Première implémentation — le framing exact DTLS-sur-PacketHandler d'UE et
+/// l'injection des records de handshake dans la voie d'émission demandent une
+/// itération EN LIVE contre le vrai client (invalidable hors client).
+///
+public sealed class DTLSHandlerComponent : HandlerComponent
+{
+ private static readonly ILogger Logger = Log.ForContext();
+
+ private DtlsPacketTransport? _transport;
+ private DtlsTransport? _dtls; // non-null une fois le handshake terminé
+ private Thread? _handshakeThread;
+ private volatile bool _established;
+ private volatile bool _failed;
+
+ public DTLSHandlerComponent(PacketHandler handler) : base(handler, nameof(DTLSHandlerComponent))
+ {
+ RequiresHandshake = true;
+ }
+
+ public override void Initialize()
+ {
+ // Inactif tant qu'on n'a pas la PSK (via BeginHandshake) : les connexions
+ // sans EncryptionToken passent en clair.
+ SetActive(false);
+ Initialized();
+ }
+
+ public override bool IsValid() => true;
+
+ ///
+ /// Démarre le serveur DTLS-PSK pour une identité (user_id) donnée.
+ /// émet un record DTLS vers le client (voie bas-niveau, hors chiffrement).
+ ///
+ public void BeginHandshake(DtlsPskStore store, string identity, Action sendRecord)
+ {
+ if (store.Get(identity) == null)
+ {
+ Logger.Warning("Pas de PSK pour l'identité {Identity} — DTLS impossible (fournir PROSPECT_DTLS_PSKS)", identity);
+ _failed = true;
+ return;
+ }
+
+ _transport = new DtlsPacketTransport(sendRecord);
+ SetActive(true);
+
+ _handshakeThread = new Thread(() =>
+ {
+ try
+ {
+ var server = new ProspectPskTlsServer(store);
+ _dtls = new DtlsServerProtocol().Accept(server, _transport);
+ _established = true;
+ Logger.Information("Handshake DTLS-PSK établi pour {Identity}", identity);
+ }
+ catch (Exception ex)
+ {
+ _failed = true;
+ Logger.Error(ex, "Handshake DTLS-PSK échoué pour {Identity}", identity);
+ }
+ })
+ {
+ IsBackground = true,
+ Name = "dtls-handshake",
+ };
+ _handshakeThread.Start();
+ }
+
+ public override void Incoming(FBitReader packet)
+ {
+ if (!IsActive() || _transport == null || _failed)
+ {
+ return; // passthrough (connexion non chiffrée)
+ }
+
+ var record = ReadAlignedBytes(packet);
+ if (record.Length == 0)
+ {
+ return;
+ }
+
+ // Alimente la pile DTLS (handshake OU données applicatives).
+ _transport.Feed(record);
+
+ if (!_established || _dtls == null)
+ {
+ // Record de handshake : consommé par le thread, rien à remonter au pipeline.
+ packet.SetData(Array.Empty(), 0);
+ return;
+ }
+
+ // Données applicatives : déchiffrer et remplacer le contenu du paquet.
+ var plain = new byte[_dtls.GetReceiveLimit()];
+ var n = _dtls.Receive(plain, 0, plain.Length, 0);
+ if (n > 0)
+ {
+ packet.SetData(plain[..n], (long)n * 8);
+ }
+ else
+ {
+ packet.SetData(Array.Empty(), 0);
+ }
+ }
+
+ public override void Outgoing(ref FBitWriter packet, FOutPacketTraits traits)
+ {
+ if (!IsActive() || !_established || _dtls == null)
+ {
+ return; // avant établissement : le handshake gère ses propres records
+ }
+
+ var plain = packet.GetData();
+ var nbytes = (int)packet.GetNumBytes();
+ _dtls.Send(plain, 0, nbytes); // le record chiffré part via le callback du transport
+ }
+
+ public override int GetReservedPacketBits()
+ {
+ // Marge d'en-tête DTLS (record 13o + AEAD ~40o) — valeur à affiner en live.
+ return 64 * 8;
+ }
+
+ public bool IsEstablished => _established;
+
+ public bool HasFailed => _failed;
+
+ 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/Dtls/DtlsPacketTransport.cs b/src/Prospect.Unreal/Net/Dtls/DtlsPacketTransport.cs
new file mode 100644
index 0000000..4ef411c
--- /dev/null
+++ b/src/Prospect.Unreal/Net/Dtls/DtlsPacketTransport.cs
@@ -0,0 +1,97 @@
+using System.Collections.Concurrent;
+using Org.BouncyCastle.Tls;
+
+namespace Prospect.Unreal.Net.Dtls;
+
+///
+/// Adapte l'API bloquante DTLS de BouncyCastle (DatagramTransport) au modèle
+/// « un paquet à la fois » du PacketHandler d'Unreal.
+///
+/// - : on y pousse les records DTLS reçus (depuis Incoming) ;
+/// BouncyCastle les consomme via .
+/// - : BouncyCastle y écrit les records à émettre ; on les
+/// récupère via le callback onSend pour les renvoyer dans le pipeline.
+///
+/// Le handshake tourne sur un thread dédié (Accept est bloquant) ; les records
+/// applicatifs, eux, sont traités de façon synchrone dans Incoming/Outgoing.
+///
+public sealed class DtlsPacketTransport : DatagramTransport
+{
+ private const int Mtu = 1500;
+
+ private readonly BlockingCollection _incoming = new(new ConcurrentQueue());
+ private readonly Action _onSend;
+
+ public DtlsPacketTransport(Action onSend)
+ {
+ _onSend = onSend;
+ }
+
+ /// Pousse un record DTLS reçu du réseau vers la pile BouncyCastle.
+ public void Feed(byte[] record)
+ {
+ if (!_incoming.IsAddingCompleted)
+ {
+ _incoming.Add(record);
+ }
+ }
+
+ public int Receive(byte[] buf, int off, int len, int waitMillis)
+ {
+ try
+ {
+ if (!_incoming.TryTake(out var record, waitMillis))
+ {
+ return -1; // timeout : BouncyCastle re-tentera / retransmettra
+ }
+
+ var n = Math.Min(len, record.Length);
+ Array.Copy(record, 0, buf, off, n);
+ return n;
+ }
+ catch (Exception)
+ {
+ return -1;
+ }
+ }
+
+ public int Receive(Span buffer, int waitMillis)
+ {
+ try
+ {
+ if (!_incoming.TryTake(out var record, waitMillis))
+ {
+ return -1;
+ }
+
+ var n = Math.Min(buffer.Length, record.Length);
+ record.AsSpan(0, n).CopyTo(buffer);
+ return n;
+ }
+ catch (Exception)
+ {
+ return -1;
+ }
+ }
+
+ public void Send(byte[] buf, int off, int len)
+ {
+ var record = new byte[len];
+ Array.Copy(buf, off, record, 0, len);
+ _onSend(record);
+ }
+
+ public void Send(ReadOnlySpan buffer)
+ {
+ _onSend(buffer.ToArray());
+ }
+
+ public int GetReceiveLimit() => Mtu;
+
+ public int GetSendLimit() => Mtu;
+
+ public void Close()
+ {
+ _incoming.CompleteAdding();
+ }
+}
diff --git a/src/Prospect.Unreal/Net/Dtls/DtlsPskStore.cs b/src/Prospect.Unreal/Net/Dtls/DtlsPskStore.cs
new file mode 100644
index 0000000..dca848e
--- /dev/null
+++ b/src/Prospect.Unreal/Net/Dtls/DtlsPskStore.cs
@@ -0,0 +1,85 @@
+using System.Globalization;
+
+namespace Prospect.Unreal.Net.Dtls;
+
+///
+/// Table identité DTLS (= PlayFab user_id) -> PSK 32 octets.
+///
+/// Les clés NE SONT JAMAIS en dur ni commitées : elles viennent de l'environnement
+/// (rendu depuis Vault). Format de PROSPECT_DTLS_PSKS :
+/// user_id:hex32[,user_id:hex32...]
+/// ex. "92EBCFE8C3EAF3AC:1E02B3F2...410F1"
+///
+/// La dérivation par user_id vit dans le client (code packé) ; tant qu'on ne l'a
+/// pas, on fournit les PSK connues (une par joueur) via cette table.
+///
+public sealed class DtlsPskStore
+{
+ private readonly Dictionary _byIdentity = new(StringComparer.OrdinalIgnoreCase);
+
+ public static DtlsPskStore FromEnvironment(string variable = "PROSPECT_DTLS_PSKS")
+ {
+ return Parse(Environment.GetEnvironmentVariable(variable));
+ }
+
+ public static DtlsPskStore Parse(string? spec)
+ {
+ var store = new DtlsPskStore();
+ if (string.IsNullOrWhiteSpace(spec))
+ {
+ return store;
+ }
+
+ foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ var sep = entry.IndexOf(':');
+ if (sep <= 0)
+ {
+ continue;
+ }
+
+ var identity = entry[..sep].Trim();
+ var psk = HexToBytes(entry[(sep + 1)..].Trim());
+ if (psk.Length == 32)
+ {
+ store._byIdentity[identity] = psk;
+ }
+ }
+
+ return store;
+ }
+
+ public bool HasKeys => _byIdentity.Count > 0;
+
+ public int Count => _byIdentity.Count;
+
+ /// PSK pour une identité (user_id), ou null si inconnue.
+ public byte[]? Get(string identity)
+ {
+ return _byIdentity.TryGetValue(identity, out var psk) ? psk : null;
+ }
+
+ private static byte[] HexToBytes(string hex)
+ {
+ if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ {
+ hex = hex[2..];
+ }
+
+ if (hex.Length % 2 != 0)
+ {
+ return Array.Empty();
+ }
+
+ var bytes = new byte[hex.Length / 2];
+ for (var i = 0; i < bytes.Length; i++)
+ {
+ if (!byte.TryParse(hex.AsSpan(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out bytes[i]))
+ {
+ return Array.Empty();
+ }
+ }
+
+ return bytes;
+ }
+}
diff --git a/src/Prospect.Unreal/Net/Dtls/ProspectPskTlsServer.cs b/src/Prospect.Unreal/Net/Dtls/ProspectPskTlsServer.cs
new file mode 100644
index 0000000..9959430
--- /dev/null
+++ b/src/Prospect.Unreal/Net/Dtls/ProspectPskTlsServer.cs
@@ -0,0 +1,45 @@
+using System.Text;
+using Org.BouncyCastle.Tls;
+using Org.BouncyCastle.Tls.Crypto;
+using Org.BouncyCastle.Tls.Crypto.Impl.BC;
+
+namespace Prospect.Unreal.Net.Dtls;
+
+///
+/// Serveur DTLS en mode PSK (BouncyCastle) pour The Cycle. L'identité PSK
+/// annoncée par le client est son user_id ; on récupère la clé correspondante
+/// dans le . Le client embarque des suites PSK-AES256
+/// (ECDHE/DHE) sur DTLS 1.2.
+///
+public sealed class ProspectPskTlsServer : PskTlsServer
+{
+ public ProspectPskTlsServer(DtlsPskStore store)
+ : base(new BcTlsCrypto(), new ProspectPskIdentityManager(store))
+ {
+ }
+
+ // Le client négocie en DTLS ; on restreint aux versions DTLS.
+ protected override ProtocolVersion[] GetSupportedVersions()
+ {
+ return new[] { ProtocolVersion.DTLSv12, ProtocolVersion.DTLSv10 };
+ }
+
+ private sealed class ProspectPskIdentityManager : TlsPskIdentityManager
+ {
+ private readonly DtlsPskStore _store;
+
+ public ProspectPskIdentityManager(DtlsPskStore store)
+ {
+ _store = store;
+ }
+
+ // Pas de hint côté serveur (le client connaît déjà son identité).
+ public byte[]? GetHint() => null;
+
+ public byte[]? GetPsk(byte[] identity)
+ {
+ var id = Encoding.UTF8.GetString(identity);
+ return _store.Get(id);
+ }
+ }
+}
diff --git a/src/Prospect.Unreal/Net/UNetConnection.cs b/src/Prospect.Unreal/Net/UNetConnection.cs
index 53b5749..68ee934 100644
--- a/src/Prospect.Unreal/Net/UNetConnection.cs
+++ b/src/Prospect.Unreal/Net/UNetConnection.cs
@@ -219,7 +219,13 @@ public abstract class UNetConnection : UPlayer
/// Reference to the PacketHandler component, for managing stateless connection handshakes
///
public StatelessConnectHandlerComponent? StatelessConnectComponent { get; private set; }
-
+
+ ///
+ /// Composant de chiffrement DTLS-PSK (inactif tant qu'un NMT_Hello avec
+ /// EncryptionToken n'a pas déclenché le handshake). Voir DTLSHandlerComponent.
+ ///
+ public DTLSHandlerComponent? DtlsComponent { get; private set; }
+
///
/// Net id of remote player on this connection. Only valid on client connections (server side).
///
@@ -1167,6 +1173,12 @@ public abstract class UNetConnection : UPlayer
StatelessConnectComponent = (StatelessConnectHandlerComponent) Handler.AddHandler();
StatelessConnectComponent.SetDriver(Driver);
+ // Chiffrement DTLS-PSK (côté serveur). Ajouté au pipeline mais inactif : il
+ // ne s'active qu'au NMT_Hello portant un EncryptionToken (cf. UWorld).
+ // NB ordre : la pile client est [DTLS, Stateless] — l'ordre exact ici est à
+ // valider en live contre le vrai client.
+ DtlsComponent = (DTLSHandlerComponent) Handler.AddHandler();
+
Handler.InitializeComponents();
MaxPacketHandlerBits = Handler.GetTotalReservedPacketBits();
diff --git a/src/Prospect.Unreal/Prospect.Unreal.csproj b/src/Prospect.Unreal/Prospect.Unreal.csproj
index 412fdbc..1088449 100644
--- a/src/Prospect.Unreal/Prospect.Unreal.csproj
+++ b/src/Prospect.Unreal/Prospect.Unreal.csproj
@@ -22,6 +22,7 @@
+
diff --git a/src/Prospect.Unreal/Runtime/UWorld.cs b/src/Prospect.Unreal/Runtime/UWorld.cs
index 867d983..fed91c0 100644
--- a/src/Prospect.Unreal/Runtime/UWorld.cs
+++ b/src/Prospect.Unreal/Runtime/UWorld.cs
@@ -4,6 +4,7 @@ using Prospect.Unreal.Core.Objects;
using Prospect.Unreal.Exceptions;
using Prospect.Unreal.Net;
using Prospect.Unreal.Net.Actors;
+using Prospect.Unreal.Net.Dtls;
using Prospect.Unreal.Net.Channels;
using Prospect.Unreal.Net.Packets.Bunch;
using Prospect.Unreal.Net.Packets.Control;
@@ -15,6 +16,9 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
{
private static readonly ILogger Logger = Log.ForContext();
+ // Table user_id -> PSK DTLS, chargée depuis l'env (rendu Vault, jamais commité).
+ private static readonly Lazy DtlsPsks = new(() => DtlsPskStore.FromEnvironment());
+
private UGameInstance? _owningGameInstance;
private AGameModeBase? _authorityGameMode;
@@ -307,8 +311,25 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
//
// For now: log and proceed to the challenge so the flow is visible
// in logs; the client will close afterwards.
- Logger.Warning("Client requires DTLS-PSK encryption (EncryptionToken={Token}); server-side DTLS not implemented — connection will be dropped by client", encryptionToken);
- connection.SendChallengeControlMessage();
+ // 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.
+ var psks = DtlsPsks.Value;
+ if (psks.Get(encryptionToken) != null && connection.DtlsComponent != null)
+ {
+ Logger.Information("DTLS-PSK: identité {Token} connue -> EncryptionAck + handshake", encryptionToken);
+ NMT_EncryptionAck.Send(connection);
+ connection.FlushNet();
+ connection.DtlsComponent.BeginHandshake(
+ psks, encryptionToken,
+ rec => connection.LowLevelSend(rec, rec.Length * 8, new FOutPacketTraits()));
+ connection.SendChallengeControlMessage();
+ }
+ else
+ {
+ Logger.Warning("DTLS-PSK requis mais aucune PSK pour {Token} (renseigner PROSPECT_DTLS_PSKS) — le client fermera la connexion", encryptionToken);
+ connection.SendChallengeControlMessage();
+ }
}
}
break;