# The Cycle: Frontier — serveur dédié gameplay (R&D netcode) Branche `game-server`. Objectif : un serveur de jeu autoritatif écrit from scratch (Prospect.Unreal, réimplémentation C# du netcode Unreal) pour du vrai co-op, sans passer par un client-hôte P2P. **Statut : bloqué au chiffrement (voir plus bas).** Client cible : **UE4 build `R3.5.0`** (`4.27.2` netcode), Steam depot 868271. ## Ce qui fonctionne (murs franchis) La connexion d'un vrai client va jusqu'à l'entrée du login : ``` Handshake stateless UDP (cookie/challenge) ✅ Reconstruction du paquet (PacketHandler) ✅ Séquençage des paquets (adopt des seq client) ✅ Alignement des bunches ✅ ← percée Canal de contrôle ouvert ✅ NMT_Hello reçu et parsé ✅ Transition login Hello → Login ✅ Chiffrement DTLS-PSK ❌ ← mur final ``` ### Percée : le bit de header spécifique R3.5.0 Le client écrit **un bit de plus** entre l'historique d'ack du `FNetPacketNotify` et le payload packet-info, que l'UE 4.27 stock (EngineNetVer 16) n'a pas. Décodage bit-à-bit d'un vrai paquet : l'en-tête fait **65 bits, pas 64**. En consommant ce bit (`bCycleExtraHeaderBit` dans `UNetConnection.ReceivedPacket`), tout se réaligne : `bHasPacketInfoPayload`, l'horloge jitter (10 bits) et `bHasServerFrameTime` tombent juste, et le premier bunch du canal de contrôle parse proprement (ChIndex 0, bOpen, bReliable = NMT_Hello). Sans ce fix, le `ChIndex` sortait en vrac (~1 049 000) et le serveur droppait/plantait. ## Le mur final : chiffrement DTLS-PSK Le client **exige** le chiffrement. Établi par reverse-engineering : - `NMT_Hello` porte `EncryptionToken` = le **PlayFab user_id** du joueur (ex. `92EBCFE8C3EAF3AC`). Vu dans l'URL de connexion du client : `...?EntityToken=?EncryptionToken=92EBCFE8C3EAF3AC`. - Pile PacketHandler du client (ses propres logs) : `[DTLSHandlerComponent, StatelessConnectHandlerComponent]`. - Le exe embarque les suites **`ECDHE-PSK-AES256-*`, `DHE-PSK-AES256-GCM-SHA384`**, la cvar **`DTLS.PreSharedKeys`**, et `DTLSPSKClientCallback` / `DTLSPSKServerCallback` → **DTLS en mode PSK** (clé pré-partagée 32 octets, identité = user_id). - Compression : **OodleNetwork** compilé, mais **aucun dictionnaire `.udic`** → pass-through (les paquets ne sont ni compressés ni chiffrés au niveau paquet ; entropie faible + longues suites de zéros le confirment). Proposer un challenge en clair (sans `NMT_EncryptionAck`) ne marche pas : le client ferme le canal de contrôle juste après. Pour finir il faudrait : (1) un **serveur DTLS-PSK** collé au framing du `DTLSHandlerComponent` d'UE, et (2) la **PSK de 32 octets** dérivée par le client à partir du user_id/EntityToken. ## Pourquoi la clé est inaccessible (statique) L'exe `Prospect-Win64-Shipping.exe` est **packé/chiffré** (protection anti-triche, BattlEye) : - **Entropie de `.text` = 8.000** (maximum = aléatoire/chiffré ; du code normal ≈ 6.3). - `.rdata` = 4.96 (normal → les strings restent lisibles, d'où les découvertes ci-dessus). - Un scan brut du `.text` (76 Mo) ne trouve que **~76 instructions** → niveau du bruit : ce n'est pas du code sur disque, c'est du chiffré déchiffré au runtime. Conséquence : Ghidra / radare2 n'analysent que du ciphertext ; **aucune référence** aux fonctions de chiffrement n'est trouvable statiquement. La dérivation de la PSK vit dans ce code chiffré. **Seule voie restante (non tentée)** : dump mémoire au runtime. Le jeu tourne sous Proton/Linux (BattlEye n'a pas de driver kernel sous Linux) → un autre process Linux peut lire `/proc//mem` et récupérer le `.text` **déchiffré**, puis l'analyser dans Ghidra pour retrouver la dérivation. Zone grise ToS, plusieurs étapes. ## Fichiers clés - `src/Prospect.Server.Game/Program.cs` — hôte du serveur de jeu (map Station, GameSession). - `src/Prospect.Unreal/Net/UNetConnection.cs` — `ReceivedPacket` : fix du bit de header (`bCycleExtraHeaderBit`), adopt des séquences client, drop gracieux des bunches. - `src/Prospect.Unreal/Runtime/UWorld.cs` — `NotifyControlMessage` : Hello/Login ; le `else` du bloc `NMT.Hello` documente le mur DTLS-PSK. ## Verdict On a amené un serveur dédié gameplay The Cycle plus loin qu'aucun projet public connu (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 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//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.