14 Commits
Author SHA1 Message Date
neckfire c2a63c6fe1 feat(netcode): chiffrement AES-256-ECB conforme FAESHandlerComponent d'UE
Build Game Server / build (push) Successful in 23s
Recycle a confirmé : 'basic aes', clé server-side déductible client, by-the-book reimpl,
pas de handshake DTLS. Remplace le GCM par AES-256-ECB + PKCS#7 + flag bit + termination
bit (mirror du source UE 4.27). Flux : SetKey -> EncryptionAck clair -> Enable -> Challenge
chiffré. Supprime AesGcmHandlerComponent.
2026-07-17 00:49:38 +02:00
neckfire 6b3a4057be docs(netcode): VERDICT — le client n'entame jamais son handshake DTLS (résolution de clé côté client), mur dur indépendant du serveur
Build Game Server / build (push) Successful in 17s
2026-07-17 00:17:07 +02:00
neckfire c6511dd046 feat(netcode): flux DTLS conforme UE — EncryptionAck seul + accept, Challenge différé
Build Game Server / build (push) Successful in 23s
Recherche du flux d'encryption UE 4.27 : le client envoie le ClientHello (serveur en
accept), NMT_EncryptionAck sans payload en clair déclenche la résolution de clé côté
client puis son EnableEncryption. On active DTLS en accept et on NE PAS envoie le
Challenge (différé jusqu'à Handshaking completed). Findings détaillés dans NETCODE-RND.
2026-07-17 00:13:53 +02:00
neckfire cc392238f7 docs(netcode): findings AES-GCM — le client ferme systématiquement (plaintext bClose) après notre réponse au Hello ; pistes restantes
Build Game Server / build (push) Successful in 15s
2026-07-16 23:58:13 +02:00
neckfire 9cb15ad9e2 fix(netcode): Incoming en sens inverse d'Outgoing (couche chiffrement externe retirée en premier)
Build Game Server / build (push) Successful in 22s
Avec >1 composant actif (stateless + AES), Incoming et Outgoing doivent être en
sens opposés. Les deux étaient en avant -> le stateless tentait de parser des octets
chiffrés. Incoming itère désormais en inverse.
2026-07-16 23:53:52 +02:00
neckfire 5c81fba79c feat(netcode): chiffrement AES-256-GCM keyé par la PSK (piste retenue)
Build Game Server / build (push) Successful in 21s
Le client n'entame pas de handshake DTLS et ferme si nos paquets post-ack sont en
clair. AesGcmHandlerComponent (System.Security.Cryptography.AesGcm, [IV12][ct][tag16]),
activé après l'EncryptionAck : ack en clair -> activation -> Challenge chiffré.
2026-07-16 23:47:00 +02:00
neckfire a3dc1e8543 docs(netcode): findings test live — client ferme après EncryptionAck (attend voie serveur chiffrée) + crash ConditionalCleanUp
Build Game Server / build (push) Successful in 15s
2026-07-16 23:38:10 +02:00
neckfire 7e34c61b2a test(dtls): EncryptionAck sans activer DTLS — le client continue le login EN CLAIR après l'ack (observé live)
Build Game Server / build (push) Successful in 21s
2026-07-16 23:33:33 +02:00
neckfire e397541418 debug(dtls): log en tête d'Incoming (état de la garde active/transport/failed)
Build Game Server / build (push) Successful in 22s
2026-07-16 23:28:37 +02:00
neckfire 3e47b0680c debug(dtls): logs Incoming/Outgoing (voir ce que reçoit le serveur après EncryptionAck)
Build Game Server / build (push) Successful in 21s
2026-07-16 23:24:34 +02:00
neckfire a21ac4d0e4 docs(netcode): mur DTLS franchi côté serveur + boucle d'itération live
Build Game Server / build (push) Successful in 14s
2026-07-16 23:07:43 +02:00
neckfire 36122578a3 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).
2026-07-16 23:06:14 +02:00
neckfireandClaude Opus 4.8 f9c44a89b3 game-server: catch-all request capture for squad-invite RE
Build Game Server / build (push) Successful in 25s
Log every unrouted POST at Warning [CAPTURE UNROUTED] (reveals native PlayFab
endpoints the emulator doesn't implement, e.g. /Group/CreateGroup, /Lobby/*) and
every social/squad/invite/friend/matchmaking call (path or body) at Information
[CAPTURE SOCIAL]. Lets us pin the exact invite mechanism when reproduced in game.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 10:35:37 +02:00
neckfire 4ec54f894b docs(game-server): study PlayFab Groups/Party emulation for squad invites
Build Game Server / build (push) Successful in 18s
2026-07-16 08:28:58 +00:00
12 changed files with 882 additions and 4 deletions
+129
View File
@@ -88,3 +88,132 @@ On a amené un serveur dédié gameplay The Cycle plus loin qu'aucun projet publ
(le projet communautaire deiteris/Prospect n'émule que les services en ligne, pas le (le projet communautaire deiteris/Prospect n'émule que les services en ligne, pas le
netcode de jeu). Le mur restant — DTLS-PSK dont la clé est derrière un packer netcode de jeu). Le mur restant — DTLS-PSK dont la clé est derrière un packer
anti-triche — est un chantier crypto + RE dynamique d'un autre ordre de grandeur. anti-triche — est un chantier crypto + RE dynamique d'un autre ordre de grandeur.
## MAJ (2026-07-16) — mur DTLS franchi (côté serveur)
- **PSK récupérée** : la clé 32 octets a été obtenue au runtime (dump du `.text`
déchiffré du client via `/proc/<pid>/mem` sous Proton — BattlEye n'a pas de driver
kernel sous Linux). Stockée dans **Vault** (`secret/the-cycle`, clé
`GAMESERVER_DTLS_PSKS`, format `user_id:hex32`), **jamais dans le repo**.
- **Serveur DTLS-PSK implémenté** (BouncyCastle) :
- `DtlsPskStore` (env `PROSPECT_DTLS_PSKS`), `DtlsPacketTransport` (pont bloquant→paquet),
`ProspectPskTlsServer` (DTLS 1.2, identité=user_id), `DTLSHandlerComponent` (pipeline).
- `UWorld` sur `NMT_Hello` chiffré : PSK connue → `NMT_EncryptionAck` + handshake.
- Compile (0 erreur). **Non validé en live.**
- **Reste (itération live obligatoire, invalidable hors client)** :
1. ordre exact du pipeline `[DTLS, Stateless]` et routage des records de handshake
(émis via `LowLevelSend` — doivent porter le framing stateless attendu) ;
2. framing DTLS-sur-PacketHandler d'UE (record layer, cookie DTLS éventuel) ;
3. bascule handshake→données applicatives.
Méthode : lancer `Prospect.Server.Game` avec la PSK, diriger le client dessus, itérer
sur les logs serveur.
## MAJ (2026-07-16, soir) — test LIVE contre le vrai client (preprod multi)
Setup : preprod multi (`the-cycle-api-rd2`, `GAMESERVER_ADDRESS=192.168.1.136:7777`) →
le client voyage vers `the-cycle-game` (branche game-server, PSK via `PROSPECT_DTLS_PSKS`).
user_id preprod = prod = `92EBCFE8C3EAF3AC` (même DB ? non, `ProspectDb_rd`, mais même Id).
**Observé (séquence réelle) :**
1. Handshake stateless : ✅ complet (challenge/cookie/ack, connexion acceptée).
2. `NMT_Hello` reçu **EN CLAIR** avec EncryptionToken=`92EBCFE8C3EAF3AC` → PSK reconnue.
3. On envoie `NMT_EncryptionAck` + `NMT_Challenge`. Le client **NE ferme plus tout de suite**
(progrès vs l'état documenté « ferme après challenge sans ack »).
4. **Le client n'envoie JAMAIS de ClientHello DTLS** (`16 fe fd`). Aucun record DTLS.
5. Le paquet suivant du client (~18 o, ex. `80E82AC4…5F00000C`) parse **en clair** comme un
**bunch `bClose=true` sur le canal de contrôle (ChIndex 0)****le client FERME**.
**Interprétation :** après `NMT_EncryptionAck`, le client attend que les paquets **serveur**
suivants soient **chiffrés** ; on lui renvoie le `Challenge` en clair → il abandonne. Donc
l'`EncryptionAck` seul ne suffit pas : il faut réellement **chiffrer la voie serveur** après
l'ack (le mur crypto de fond, inchangé). Activer notre composant DTLS ne sert à rien tant que
le client ne fait pas de handshake DTLS de son côté — piste à creuser : est-ce de l'**AES-GCM
keyé par la PSK** (SetEncryptionData/EnableEncryption d'UE) plutôt que du DTLS-handshake ?
**Bug serveur concret trouvé :** `UChannel.ConditionalCleanUp` (Prospect.Unreal, ~l.822) =
`throw new NotImplementedException()`**crash du tick** dès qu'un canal se ferme (bClose).
À implémenter (indépendant du crypto).
**Acquis réutilisables :** on passe le handshake + le Hello + l'EncryptionAck est accepté ;
la PSK par user_id est branchée bout-en-bout (Vault → env → serveur). Le blocage net =
chiffrement de la voie serveur post-ack.
## MAJ (2026-07-16, nuit) — tentative AES-256-GCM keyé par la PSK
Implémenté `AesGcmHandlerComponent` (`System.Security.Cryptography.AesGcm`, format
`[IV 12o][ciphertext][tag 16o]`), activé après l'EncryptionAck (ack en clair → activation →
Challenge chiffré). Corrigé aussi l'ordre du pipeline : **Incoming doit itérer en sens
INVERSE d'Outgoing** (`PacketHandler.Incoming_Internal`) — sinon, avec 2 composants actifs
(stateless + AES), le stateless tente de parser des octets chiffrés. (Les deux étaient en avant ;
n'avait jamais compté car un seul composant actif jusqu'ici.)
**Observé (test live) :** notre Challenge part bien chiffré (`[AES] Outgoing 30o→58o`), mais le
client répond **toujours** par un **paquet plaintext de ~18 o** de structure constante
(`…41 00 XX FF 5F 00 00 0C`) = un **`bClose` du canal de contrôle**, jamais rien qui ressemble
à de l'AES (haute entropie ≥28o). **Le client ne chiffre jamais son côté.**
**Conclusion :** quelle que soit notre réponse au `Hello` (challenge clair, chiffré AES, ou
tentative DTLS), le client **ferme systématiquement le canal de contrôle en clair** juste après.
L'AES-GCM tel qu'implémenté ne le fait pas basculer en chiffré. Pistes restantes (deep RE, non
tranchées) : (a) framing AES-GCM exact d'UE (schéma d'IV/nonce, AAD, position) ≠ notre IV aléatoire
préfixé ; (b) c'est bien du DTLS-handshake et le client attend un ServerHello DTLS ; (c) notre
réponse au Hello est incomplète (ordre/contenu des NMT) indépendamment du chiffrement.
Chaque test = ~4 min (CI + redeploy). Bugs serveur secondaires à corriger : `ConditionalCleanUp`
(stub) + `ArgumentOutOfRangeException` quand on vide un paquet trop court.
**Décision : checkpoint.** Tout est commité/documenté. La suite = RE netcode profonde multi-session ;
et même résolue, ce n'est que le login — la simulation gameplay reste hors de portée réaliste.
## Flux de chiffrement DTLS d'UE 4.27 (recherche, 2026-07-17)
Sources : KB Epic « Enable Encryption via Packet Handler Components », doc API
(EnableEncryptionServer/SetEncryptionData/FEncryptionData), UE-95508 (ack chiffré si
renvoyé), UE-171638 (module DTLS exige OpenSSL), logs DTLS/PSK réels (Satisfactory #453).
Le source du plugin DTLS est gated (points reconstruits signalés).
**Corrections clés :**
- **Le CLIENT envoie le ClientHello ; le serveur ATTEND (accept), ne parle jamais en premier.**
Donc l'AES-GCM autonome = impasse (c'est bien du DTLS, records `16 fe fd` standard OpenSSL).
- **`NMT_EncryptionAck` ne porte AUCUNE clé.** À sa réception, le client appelle
`ReceivedNetworkEncryptionAck`**résout lui-même** sa `FEncryptionData` (Key + Identifier)
→ appelle **`EnableEncryption` côté client** → **c'est CE qui déclenche son ClientHello**.
- **Séquence serveur** : `SetEncryptionData(Key,Identifier)``NMT_EncryptionAck` **en clair, sans
payload** → **puis** `EnableEncryption` (mode accept). Ne jamais chiffrer l'ack.
- **Le `NMT_Challenge` doit être DIFFÉRÉ** : le handler DTLS met les bunches applicatifs en file
jusqu'à `Handshaking completed`. (Notre impl l'envoyait tout de suite → à corriger.)
- **PSK** : PSK = `FEncryptionData.Key`, identité PSK = `FEncryptionData.Identifier` ; ressortie via
`DTLSPSKServerCallback` (hook OpenSSL) quand le ClientHello arrive. Le serveur n'envoie jamais la clé.
- **Couches** : DTLS **sous** le stateless (entrée : retirer stateless puis déchiffrer DTLS ;
Outgoing forward / Incoming reverse).
**Si le client ferme SANS ClientHello (notre cas)** → cause la plus probable = **échec de résolution
de clé côté client** (le vrai client shipping attend peut-être la clé de SON backend/matchmaking,
non émulé — ou dérivation/Identifier différents), pas notre framing DTLS. C'est potentiellement un
mur dur (dépend du backend réel du client).
**Test informé RÉALISÉ (2026-07-17)** : `EncryptionAck` seul (clair, sans payload) + DTLS **accept** +
**PAS de Challenge**. **Résultat : le client n'envoie TOUJOURS pas de ClientHello** (zéro `16 fe fd`
dans les datagrammes bruts) et ferme avec le `bClose` plaintext habituel.
## VERDICT (mur confirmé) — résolution de clé CÔTÉ CLIENT
Le blocage n'est **pas** notre framing serveur (DTLS ou AES) : le client **n'entame jamais son
handshake DTLS**. Séquence définitive observée, quel que soit ce qu'on renvoie :
`stateless OK → NMT_Hello (clair) → notre NMT_EncryptionAck → client ferme (bClose), pas de ClientHello`.
D'après la recherche du flux UE : le ClientHello n'est émis que si le client, dans
`ReceivedNetworkEncryptionAck`, **résout une `FEncryptionData` valide et appelle `EnableEncryption`
côté client**. Ici il ne le fait pas → il ferme. On **ne peut pas forcer** ça depuis le serveur.
Cause la plus probable : le vrai client shipping attend sa clé/identité **de SON backend**
(réponse matchmaking/PlayFab), non émulée — ou une dérivation/`Identifier` qu'on ne fournit pas au
bon endroit. La PSK a été récupérée (dump mémoire) mais ça ne suffit pas : c'est la **décision
client d'activer le chiffrement** qui manque, et sa logique vit dans le binaire packé.
**Pour aller plus loin il faudrait** : RE de la fonction `ReceivedNetworkEncryptionAck`/résolution de
clé du client (dans le `.text` déchiffré, même méthode que pour la PSK) pour savoir **d'où** il attend
sa clé, puis la lui fournir (probablement côté backend rd2). Chantier RE profond, incertain.
**Statut : mur dur, checkpoint.** Acquis solides et réutilisables : handshake + Hello + EncryptionAck ;
serveur DTLS-PSK ET AES-GCM implémentés ; ordre pipeline corrigé (Incoming inverse d'Outgoing) ;
flux UE documenté. Le verrou restant dépend du client, pas du serveur.
+103
View File
@@ -0,0 +1,103 @@
# Étude — Émulation PlayFab Groups/Party pour l'invite de squad
> Objectif : permettre à un joueur d'**inviter un ami** à faire équipe (squad) sur le serveur
> privé. Étude du mécanisme, de l'état actuel, d'une conception d'émulation et d'un plan.
> Branche : `game-server`.
## 1. État actuel (ce qui existe déjà)
**Roster de squad en mémoire**`Services/Squad/SquadService.cs` :
- Squads clés par un **`squadId` fourni par le client** (`JoinOrCreate(squadId, userId, …)`).
- Membre = profil (playerId, displayName), `onlineState`, `isReady`, `selectedMap`, `isLeader`.
- `_userToSquad` permet à `TryGetCompleteSquadInfo` (qui ne porte pas de squadId) de retrouver la squad du joueur.
**Fonctions CloudScript squad présentes** : `TryGetCompleteSquadInfo`, `SquadMemberReadyForMatch`
(pousse déjà via SignalR), `SquadMemberSelectedMap`, `SquadMemberStartingDeployFlow`,
`GetFriendList`, `ClientsideFriendsImport`.
**Ce qui MANQUE** (cœur du problème) : aucune fonction de **création de squad**, d'**invitation**,
d'**acceptation/refus**, de **join/leave/kick**. Le flux actuel suppose que le client **connaît déjà
un `squadId`** (party formée ailleurs) et se contente d'y rattacher les membres.
**Infra de push temps-réel DISPONIBLE**`Hubs/CycleHub.cs` + `Hubs/SignalRConnectionRegistry.cs` :
le client se connecte au hub avec `?uid=<playerId>` ; le registry mappe `uid → connectionId`. Le
serveur peut donc **pousser un message ciblé** à un joueur précis (`registry.GetConnection(uid)` +
`IHubContext<CycleHub>.Clients.Client(conn).SendAsync(<event>, payload)`). C'est déjà utilisé par
`SquadMemberReadyForMatch` et par le signal « travel to match » du matchmaking. **→ Le canal de
livraison de l'invite existe déjà.**
## 2. Comment The Cycle forme-t-il les squads ? (hypothèses + preuves)
Le `squadId` étant **fourni par le client**, la party est créée là où le client obtient cet id.
Quatre mécanismes candidats côté PlayFab/Steam :
| # | Mécanisme | Ce que ça implique côté serveur | Indice |
|---|---|---|---|
| A | **PlayFab Entity Groups** (`/Group/CreateGroup`, `/Group/InviteToGroup`, `/Group/AcceptGroupInvitation`, `/Group/ListGroupMembers`…) | Implémenter le sous-ensemble Groups (entités déjà émulées). `squadId` = group entity id. | Fort : squads persistantes chez PlayFab passent par Groups ; l'id opaque partagé colle. |
| B | **PlayFab Lobby** (Multiplayer : `CreateLobby`/`JoinLobby`/`InviteToLobby` via connection string) | Implémenter l'API Lobby. `squadId` = lobbyId. | Moyen : plutôt matchmaking S3+. |
| C | **Lobby Steam** (invite via overlay Steam) + backend qui suit | Rien à créer pour l'invite (100% Steam) ; le serveur ne fait que suivre le `squadId` rapporté. | Moyen : S2 = Steam-only ; explique le `squadId` client. Mais alors l'invite n'est pas pilotable serveur. |
| D | **Fonctions CloudScript d'invite** (non observées) | Implémenter `InvitePlayerToSquad`/`RespondToSquadInvite`/… | Faible : aucune trace. |
**Preuve manquante (bloquant)** : les logs prod de la session à 2 ont été **écrasés** par nos
redéploiements. Impossible aujourd'hui de voir l'appel exact d'invite. De plus, l'émulateur peut
**404 silencieusement** un endpoint PlayFab natif non routé (ex. `/Group/CreateGroup`) → un tel
appel n'apparaît pas comme fonction CloudScript. **Il faut donc capturer l'appel réel** (voir §5,
Phase 0) — d'autant que le **bouton « inviter » n'apparaît pas tant que la tuile ami est vide**
(bug de forme `GetFriendList`, corrigé PR #15, à valider en jeu). Tant que la liste d'amis ne
s'affiche pas, aucune invite ne peut même être tentée.
## 3. Conception d'émulation proposée (indépendante du mécanisme exact)
Quel que soit A/B/C/D, les briques serveur à construire sont les mêmes :
**3.1 Étendre `SquadService`** (états d'invite) :
- `CreateSquad(leaderUserId) → squadId` (GUID serveur si le client n'en impose pas).
- `Invite(squadId, fromUserId, toUserId)` → enregistre une **invitation en attente** `{squadId, from, to, expiresAt}`.
- `RespondToInvite(toUserId, squadId, accept)` → si accept : `JoinOrCreate` ; sinon purge.
- `Leave` / `Kick(byLeader)` / transfert de leadership à la sortie du leader.
- `GetPendingInvites(userId)` (fallback poll).
**3.2 Surface d'API** — deux options selon la capture (§5) :
- **Si Groups (A)** : router `POST /Group/CreateGroup`, `/Group/InviteToGroup`,
`/Group/AcceptGroupInvitation`, `/Group/ListGroupMembers`, `/Group/RemoveMembers` vers
`SquadService` (les EntityToken sont déjà émulés). C'est le plus « natif ».
- **Si CloudScript (D) / custom** : ajouter les fonctions `[CloudScriptFunction(...)]`
correspondantes (noms/shapes calqués sur la capture).
- **Si Steam (C)** : rien pour l'invite (Steam) ; s'assurer seulement que le roster se peuple bien
quand les deux clients rapportent le même `squadId` (déjà géré par `SquadService`).
**3.3 Livraison temps-réel de l'invite** (réutilise l'infra existante) :
- À l'invitation, pousser à l'invité : `registry.GetConnection(toUserId)` +
`hub.Clients.Client(conn).SendAsync("<SquadInviteReceived>", payload)`.
- **Nom d'event + payload = à confirmer côté client** (le client doit écouter cet event). Fallback :
`GetPendingSquadInvites` (CloudScript) que le client poll à l'ouverture du menu social.
**3.4 Persistance** : le roster peut rester en mémoire (petit serveur), mais les invitations
gagnent à être **persistées** (UserData `PendingSquadInvites`) pour survivre à un reload/redeploy et
au cas « ami à la station » — combiné au push SignalR.
## 4. Limite structurelle importante (à dire clairement)
Émuler l'invite permet de **former une squad et de se mettre prêt** ensemble (lobby/ready-up).
Mais **déployer réellement ensemble dans le même raid** nécessite le **serveur de jeu dédié**
(`Prospect.Server.Game`), qui est bloqué au mur **DTLS-PSK** (cf. reste de cette branche). Les raids
S2 sont **hébergés côté client** (solo) : sans serveur dédié, deux joueurs ne peuvent pas partager
la même instance de raid. Donc :
- **Invite + squad + ready-up** : émulable via l'API (cette étude).
- **Co-op en raid** : dépend du serveur dédié (autre chantier, DTLS-PSK).
## 5. Plan par phases
- **Phase 0 — Capture (débloque tout)** : valider le fix `GetFriendList` en jeu (la tuile ami doit
afficher le pseudo) → le bouton « inviter » réapparaît → tenter une invite et **capturer les
appels** (ajouter un logging *catch-all* des requêtes non routées + corps, pour voir un éventuel
`/Group/…` 404). Résultat : on sait A/B/C/D.
- **Phase 1 — Invite** : implémenter la surface identifiée + états `SquadService` + push SignalR ;
persister les invitations.
- **Phase 2 — Ready-up/déploiement groupé** : compléter (déjà amorcé) ; le déploiement co-op réel
reste gated par le serveur dédié.
## 6. Prochaine action concrète
Repro en jeu (liste d'amis corrigée) + logging catch-all → capturer l'appel d'invite. Sans cette
capture, toute implémentation d'invite serait une supposition sur les noms/shapes attendus par le
client (paks chiffrés → pas de RE statique).
@@ -9,6 +9,14 @@ public class RequestLoggerMiddleware
private readonly ILogger<RequestLoggerMiddleware> _logger; private readonly ILogger<RequestLoggerMiddleware> _logger;
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
// Keywords used to surface social/squad/invite traffic while reverse-engineering the
// squad-invite flow (see SQUAD-EMULATION.md). Matched against the path AND the body
// (the CloudScript function name lives in the body of /Client/ExecuteFunction).
private static readonly string[] SocialKeywords =
{
"group", "party", "lobby", "squad", "invite", "friend", "social", "matchmak",
};
public RequestLoggerMiddleware(ILogger<RequestLoggerMiddleware> logger, RequestDelegate next) public RequestLoggerMiddleware(ILogger<RequestLoggerMiddleware> logger, RequestDelegate next)
{ {
_logger = logger; _logger = logger;
@@ -32,6 +40,27 @@ public class RequestLoggerMiddleware
} }
await _next(context); await _next(context);
// ── Capture pass (squad-invite RE, see SQUAD-EMULATION.md) ──────────────
// Runs AFTER the pipeline so we know whether the request was actually routed.
if (context.Request.Method == "POST")
{
var path = context.Request.Path.Value ?? "";
var haystack = (path + " " + body).ToLowerInvariant();
// Any POST that fell through to a 404 = an endpoint the emulator does NOT implement
// (e.g. a native PlayFab /Group/CreateGroup or /Lobby/* the client tried to call).
if (context.Response.StatusCode == 404)
{
_logger.LogWarning("[CAPTURE UNROUTED] {Method} {Url} -> 404 | Body {Body}",
context.Request.Method, context.Request.GetDisplayUrl(), body);
}
else if (SocialKeywords.Any(k => haystack.Contains(k)))
{
_logger.LogInformation("[CAPTURE SOCIAL] {Url} ({Status}) | Body {Body}",
context.Request.GetDisplayUrl(), context.Response.StatusCode, body);
}
}
} }
private static async Task<string> RequestAsync(HttpRequest request) private static async Task<string> RequestAsync(HttpRequest request)
@@ -0,0 +1,159 @@
using System.Security.Cryptography;
using Prospect.Unreal.Serialization;
using Serilog;
namespace Prospect.Unreal.Net;
/// <summary>
/// Chiffrement réseau conforme au FAESHandlerComponent d'UE 4.27 : AES-256-ECB,
/// padding PKCS#7 (défaut OpenSSL), clé 32 o, PAS d'IV. Format sur le fil (mirror
/// du source UE) :
/// [1 bit flag "encryption enabled"][ciphertext bit-packé, décalé de 1 bit]
/// Avant chiffrement, un "termination bit" (=1) est écrit sur le plaintext pour
/// préserver le compte de bits exact (le dernier octet déchiffré porte ce bit haut).
///
/// Le handler ne s'active qu'après EnableEncryption (déclenché à réception du
/// NMT_Hello chiffré côté serveur) ; avant, passthrough (pas de flag).
/// </summary>
public sealed class AesHandlerComponent : HandlerComponent
{
private static readonly ILogger Logger = Log.ForContext<AesHandlerComponent>();
private const int KeySize = 32;
private const int BlockSize = 16;
private byte[]? _key;
public AesHandlerComponent(PacketHandler handler) : base(handler, nameof(AesHandlerComponent))
{
}
public override void Initialize()
{
SetActive(false);
Initialized();
}
public override bool IsValid() => true;
/// <summary>SetEncryptionData : pose la clé (32 o) sans activer le chiffrement sortant.</summary>
public void SetKey(byte[] key)
{
if (key.Length != KeySize)
{
Logger.Warning("[AES] Clé de taille {N} (attendu {K}) — ignorée", key.Length, KeySize);
return;
}
_key = key;
Logger.Information("[AES] Clé posée ({N} o)", key.Length);
}
/// <summary>EnableEncryption : à partir d'ici la sortie est chiffrée. À appeler APRÈS SetKey
/// et APRÈS avoir envoyé le NMT_EncryptionAck en clair.</summary>
public void Enable()
{
SetActive(true);
Logger.Information("[AES] Chiffrement AES-256-ECB activé");
}
public override void Incoming(FBitReader packet)
{
if (!IsActive())
{
return; // pas encore de flag avant activation
}
var flag = packet.ReadBit();
if (!flag)
{
return; // paquet en clair (flag=0) — passthrough du reste
}
if (_key == null || packet.GetBytesLeft() <= 0)
{
return;
}
var n = packet.GetBytesLeft();
var cipher = new byte[n]; // zéro-init (dernier octet à 0 comme UE)
var bitsLeft = packet.GetBitsLeft();
unsafe
{
fixed (byte* p = cipher)
{
packet.SerializeBits(p, bitsLeft); // relit tous les bits restants -> ré-aligne le ciphertext
}
}
byte[] plain;
try
{
using var aes = Aes.Create();
aes.Key = _key;
plain = aes.DecryptEcb(cipher, PaddingMode.PKCS7);
}
catch (CryptographicException ex)
{
Logger.Warning("[AES] Déchiffrement échoué ({M}) — {N}o head={H}", ex.Message, n,
Convert.ToHexString(cipher.AsSpan(0, Math.Min(n, 16))));
packet.SetError();
return;
}
// Taille réelle en bits = position du bit à 1 le plus haut du dernier octet (termination bit).
if (plain.Length == 0)
{
packet.SetError();
return;
}
var last = plain[^1];
var bits = plain.Length * 8 - 1;
while ((last & 0x80) == 0 && bits >= 0)
{
last <<= 1;
bits--;
}
if (bits < 0)
{
packet.SetError(); // dernier octet nul -> malformé
return;
}
Logger.Information("[AES] Incoming déchiffré {N}o -> {B} bits", n, bits);
packet.SetData(plain, bits);
}
public override void Outgoing(ref FBitWriter packet, FOutPacketTraits traits)
{
if (!IsActive())
{
return; // passthrough (pas de flag avant activation)
}
// Termination bit -> le plaintext est byte-aligné (FBitWriter complète en zéros).
packet.WriteBit(true);
var plainLen = (int)packet.GetNumBytes();
var plaintext = packet.GetData();
byte[] cipher;
using (var aes = Aes.Create())
{
aes.Key = _key!;
cipher = aes.EncryptEcb(plaintext.AsSpan(0, plainLen), PaddingMode.PKCS7);
}
var newPacket = new FBitWriter((long)cipher.Length * 8 + 2, true, false);
newPacket.WriteBit(true); // flag = encryption enabled
unsafe
{
fixed (byte* p = cipher)
{
newPacket.Serialize(p, cipher.Length); // ciphertext décalé de 1 bit derrière le flag
}
}
packet = newPacket;
}
// 1 (flag) + 1 (termination) + 7 (bourrage octet) + 128 (bloc PKCS#7) — cf. UE.
public override int GetReservedPacketBits() => 2 + 7 + BlockSize * 8;
public bool IsEnabled => IsActive();
}
@@ -0,0 +1,170 @@
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(rec =>
{
Logger.Information("[DTLS] Outgoing record {N}o head={H}", rec.Length,
Convert.ToHexString(rec.AsSpan(0, Math.Min(rec.Length, 12))));
sendRecord(rec);
});
SetActive(true);
Logger.Information("[DTLS] BeginHandshake pour {Identity} — serveur DTLS-PSK démarré", identity);
_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)
{
Logger.Information("[DTLS] Incoming CALLED active={A} transportNull={T} failed={F} established={E} bitsLeft={B}",
IsActive(), _transport == null, _failed, _established, packet.GetBitsLeft());
if (!IsActive() || _transport == null || _failed)
{
return; // passthrough (connexion non chiffrée)
}
var record = ReadAlignedBytes(packet);
Logger.Information("[DTLS] Incoming nbytes={N} head={H}", record.Length,
Convert.ToHexString(record.AsSpan(0, Math.Min(record.Length, 12))));
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);
}
}
}
+7 -1
View File
@@ -192,8 +192,14 @@ public class PacketHandler
UpdateInitialState(); UpdateInitialState();
} }
foreach (var component in _handlerComponents) // Incoming = sens INVERSE d'Outgoing (Outgoing itère en avant). Indispensable
// dès qu'il y a >1 composant actif : la couche ajoutée en dernier à l'émission
// (ex. chiffrement AES, la plus externe) doit être retirée en premier à la
// réception. Sans ça, le stateless tenterait de parser des octets chiffrés.
for (var idx = _handlerComponents.Count - 1; idx >= 0; idx--)
{ {
var component = _handlerComponents[idx];
if (processPacketReader.GetPosBits() != 0 && !component.CanReadUnaligned()) if (processPacketReader.GetPosBits() != 0 && !component.CanReadUnaligned())
{ {
RealignPacket(processPacketReader); RealignPacket(processPacketReader);
+19
View File
@@ -220,6 +220,15 @@ public abstract class UNetConnection : UPlayer
/// </summary> /// </summary>
public StatelessConnectHandlerComponent? StatelessConnectComponent { get; private set; } 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>Composant de chiffrement AES-256-ECB (conforme FAESHandlerComponent d'UE), keyé par la PSK.</summary>
public AesHandlerComponent? AesComponent { get; private set; }
/// <summary> /// <summary>
/// Net id of remote player on this connection. Only valid on client connections (server side). /// Net id of remote player on this connection. Only valid on client connections (server side).
/// </summary> /// </summary>
@@ -1167,6 +1176,16 @@ public abstract class UNetConnection : UPlayer
StatelessConnectComponent = (StatelessConnectHandlerComponent) Handler.AddHandler<StatelessConnectHandlerComponent>(); StatelessConnectComponent = (StatelessConnectHandlerComponent) Handler.AddHandler<StatelessConnectHandlerComponent>();
StatelessConnectComponent.SetDriver(Driver); 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>();
// Chiffrement AES-256-ECB conforme FAESHandlerComponent d'UE (keyé par la PSK).
// Ajouté EN DERNIER -> couche la plus externe (chiffre tout ce qui précède).
// Inactif jusqu'à EnableEncryption (déclenché au NMT_Hello chiffré, cf. UWorld).
AesComponent = (AesHandlerComponent) Handler.AddHandler<AesHandlerComponent>();
Handler.InitializeComponents(); Handler.InitializeComponents();
MaxPacketHandlerBits = Handler.GetTotalReservedPacketBits(); MaxPacketHandlerBits = Handler.GetTotalReservedPacketBits();
@@ -22,6 +22,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Serilog" Version="4.0.0" /> <PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+36 -1
View File
@@ -4,6 +4,7 @@ using Prospect.Unreal.Core.Objects;
using Prospect.Unreal.Exceptions; using Prospect.Unreal.Exceptions;
using Prospect.Unreal.Net; using Prospect.Unreal.Net;
using Prospect.Unreal.Net.Actors; using Prospect.Unreal.Net.Actors;
using Prospect.Unreal.Net.Dtls;
using Prospect.Unreal.Net.Channels; using Prospect.Unreal.Net.Channels;
using Prospect.Unreal.Net.Packets.Bunch; using Prospect.Unreal.Net.Packets.Bunch;
using Prospect.Unreal.Net.Packets.Control; 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>(); 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 UGameInstance? _owningGameInstance;
private AGameModeBase? _authorityGameMode; private AGameModeBase? _authorityGameMode;
@@ -307,10 +311,41 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
// //
// For now: log and proceed to the challenge so the flow is visible // For now: log and proceed to the challenge so the flow is visible
// in logs; the client will close afterwards. // 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.
// PISTE AES-GCM (test live précédent : le client ferme quand nos
// paquets post-ack sont EN CLAIR → il attend une voie serveur chiffrée,
// sans handshake DTLS). On envoie l'EncryptionAck EN CLAIR, on active
// AES-256-GCM keyé par la PSK, puis le Challenge part CHIFFRÉ.
// Flux DTLS d'UE (cf. recherche) : NMT_EncryptionAck EN CLAIR sans
// payload -> le client résout SA clé (ReceivedNetworkEncryptionAck),
// appelle EnableEncryption côté client, ce qui déclenche SON ClientHello.
// Le serveur passe en mode ACCEPT (attend le ClientHello, ne parle pas
// en premier) et NE DOIT PAS envoyer le Challenge maintenant : il est
// différé jusqu'à la fin du handshake DTLS.
// Flux d'encryption UE (AES-256-ECB, cf. FAESHandlerComponent) :
// SetEncryptionData(clé) -> NMT_EncryptionAck EN CLAIR -> EnableEncryption
// (à partir d'ici la sortie serveur est chiffrée) -> Challenge chiffré.
// Le client déduit la même clé du token, active son chiffrement, déchiffre
// le Challenge et répond (Login) chiffré. Clé = notre PSK (server-side).
var aesKey = DtlsPsks.Value.Get(encryptionToken);
if (aesKey != null && connection.AesComponent != null)
{
Logger.Information("AES: identité {Token} -> SetKey + EncryptionAck (clair) + Enable + Challenge (chiffré)", encryptionToken);
connection.AesComponent.SetKey(aesKey);
NMT_EncryptionAck.Send(connection);
connection.FlushNet(); // ack EN CLAIR (avant Enable)
connection.AesComponent.Enable(); // sortie chiffrée à partir d'ici
connection.SendChallengeControlMessage(); // Challenge chiffré
}
else
{
Logger.Warning("Aucune PSK pour {Token} (PROSPECT_DTLS_PSKS) — challenge en clair, le client fermera", encryptionToken);
connection.SendChallengeControlMessage(); connection.SendChallengeControlMessage();
} }
} }
}
break; break;
} }