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;
}
}