From 36122578a3824984d5e4e3120fa0dab53f5435ce Mon Sep 17 00:00:00 2001 From: neckfire Date: Thu, 16 Jul 2026 23:06:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(netcode):=20serveur=20DTLS-PSK=20(c=C3=B4t?= =?UTF-8?q?=C3=A9=20serveur)=20=E2=80=94=20franchit=20le=20mur=20du=20chif?= =?UTF-8?q?frement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémente le pendant serveur du DTLSHandlerComponent d'UE (la pile client est [DTLS, Stateless]) : le client exige un DTLS en mode PSK dont l'identité = user_id. - DtlsPskStore : table user_id -> PSK 32o, chargée depuis l'env (PROSPECT_DTLS_PSKS, rendu Vault) — aucune clé en dur ni commitée. - DtlsPacketTransport : pont entre l'API bloquante DTLS de BouncyCastle et le modèle paquet-par-paquet du PacketHandler (handshake sur thread dédié). - ProspectPskTlsServer : serveur DTLS-PSK BouncyCastle (DTLS 1.2, identité=user_id). - DTLSHandlerComponent : composant du pipeline (Incoming déchiffre / Outgoing chiffre), activé au NMT_Hello portant un EncryptionToken. - UWorld.NotifyControlMessage : sur NMT_Hello chiffré, si la PSK de l'identité est connue -> NMT_EncryptionAck + BeginHandshake ; sinon warning (comme avant). Compile (0 erreur, BouncyCastle 2.4). ⚠️ Le framing exact DTLS-sur-PacketHandler et le routage des records de handshake demandent une ITÉRATION EN LIVE contre le vrai client (invalidable hors client). --- .../Net/DTLSHandlerComponent.cs | 159 ++++++++++++++++++ .../Net/Dtls/DtlsPacketTransport.cs | 97 +++++++++++ src/Prospect.Unreal/Net/Dtls/DtlsPskStore.cs | 85 ++++++++++ .../Net/Dtls/ProspectPskTlsServer.cs | 45 +++++ src/Prospect.Unreal/Net/UNetConnection.cs | 14 +- src/Prospect.Unreal/Prospect.Unreal.csproj | 1 + src/Prospect.Unreal/Runtime/UWorld.cs | 25 ++- 7 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 src/Prospect.Unreal/Net/DTLSHandlerComponent.cs create mode 100644 src/Prospect.Unreal/Net/Dtls/DtlsPacketTransport.cs create mode 100644 src/Prospect.Unreal/Net/Dtls/DtlsPskStore.cs create mode 100644 src/Prospect.Unreal/Net/Dtls/ProspectPskTlsServer.cs 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;