feat(netcode): serveur DTLS-PSK (côté serveur) — franchit le mur du chiffrement
Build Game Server / build (push) Successful in 20s

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).
This commit is contained in:
2026-07-16 23:06:14 +02:00
parent f9c44a89b3
commit 36122578a3
7 changed files with 423 additions and 3 deletions
@@ -0,0 +1,159 @@
using Org.BouncyCastle.Tls;
using Prospect.Unreal.Net.Dtls;
using Prospect.Unreal.Serialization;
using Serilog;
namespace Prospect.Unreal.Net;
/// <summary>
/// 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).
/// </summary>
public sealed class DTLSHandlerComponent : HandlerComponent
{
private static readonly ILogger Logger = Log.ForContext<DTLSHandlerComponent>();
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;
/// <summary>
/// Démarre le serveur DTLS-PSK pour une identité (user_id) donnée. <paramref name="sendRecord"/>
/// émet un record DTLS vers le client (voie bas-niveau, hors chiffrement).
/// </summary>
public void BeginHandshake(DtlsPskStore store, string identity, Action<byte[]> 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<byte>(), 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<byte>(), 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<byte>();
}
var buf = new byte[bytes];
unsafe
{
fixed (byte* p = buf)
{
packet.Serialize(p, bytes);
}
}
return buf;
}
}
@@ -0,0 +1,97 @@
using System.Collections.Concurrent;
using Org.BouncyCastle.Tls;
namespace Prospect.Unreal.Net.Dtls;
/// <summary>
/// Adapte l'API bloquante DTLS de BouncyCastle (DatagramTransport) au modèle
/// « un paquet à la fois » du PacketHandler d'Unreal.
///
/// - <see cref="Feed"/> : on y pousse les records DTLS reçus (depuis Incoming) ;
/// BouncyCastle les consomme via <see cref="Receive"/>.
/// - <see cref="Send"/> : BouncyCastle y écrit les records à émettre ; on les
/// récupère via le callback <c>onSend</c> 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.
/// </summary>
public sealed class DtlsPacketTransport : DatagramTransport
{
private const int Mtu = 1500;
private readonly BlockingCollection<byte[]> _incoming = new(new ConcurrentQueue<byte[]>());
private readonly Action<byte[]> _onSend;
public DtlsPacketTransport(Action<byte[]> onSend)
{
_onSend = onSend;
}
/// <summary>Pousse un record DTLS reçu du réseau vers la pile BouncyCastle.</summary>
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<byte> 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<byte> buffer)
{
_onSend(buffer.ToArray());
}
public int GetReceiveLimit() => Mtu;
public int GetSendLimit() => Mtu;
public void Close()
{
_incoming.CompleteAdding();
}
}
@@ -0,0 +1,85 @@
using System.Globalization;
namespace Prospect.Unreal.Net.Dtls;
/// <summary>
/// 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.
/// </summary>
public sealed class DtlsPskStore
{
private readonly Dictionary<string, byte[]> _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;
/// <summary>PSK pour une identité (user_id), ou null si inconnue.</summary>
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<byte>();
}
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<byte>();
}
}
return bytes;
}
}
@@ -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;
/// <summary>
/// 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 <see cref="DtlsPskStore"/>. Le client embarque des suites PSK-AES256
/// (ECDHE/DHE) sur DTLS 1.2.
/// </summary>
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);
}
}
}
+12
View File
@@ -220,6 +220,12 @@ public abstract class UNetConnection : UPlayer
/// </summary>
public StatelessConnectHandlerComponent? StatelessConnectComponent { get; private set; }
/// <summary>
/// Composant de chiffrement DTLS-PSK (inactif tant qu'un NMT_Hello avec
/// EncryptionToken n'a pas déclenché le handshake). Voir DTLSHandlerComponent.
/// </summary>
public DTLSHandlerComponent? DtlsComponent { get; private set; }
/// <summary>
/// Net id of remote player on this connection. Only valid on client connections (server side).
/// </summary>
@@ -1167,6 +1173,12 @@ public abstract class UNetConnection : UPlayer
StatelessConnectComponent = (StatelessConnectHandlerComponent) Handler.AddHandler<StatelessConnectHandlerComponent>();
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<DTLSHandlerComponent>();
Handler.InitializeComponents();
MaxPacketHandlerBits = Handler.GetTotalReservedPacketBits();
@@ -22,6 +22,7 @@
<ItemGroup>
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
</ItemGroup>
<ItemGroup>
+23 -2
View File
@@ -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<UWorld>();
// Table user_id -> PSK DTLS, chargée depuis l'env (rendu Vault, jamais commité).
private static readonly Lazy<DtlsPskStore> 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;