feat(netcode): serveur DTLS-PSK (côté serveur) — franchit le mur du chiffrement
Build Game Server / build (push) Successful in 20s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,9 +311,26 @@ 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);
|
||||
// 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user