46 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
neckfire f7048ff173 Merge pull request 'chore(game-server): merge main (emulator fixes: contracts, shop, fortuna, friends, admin, presence)' (#16) from main into game-server
Build Game Server / build (push) Successful in 26s
2026-07-16 08:25:21 +00:00
neckfire 91bdde3797 Merge pull request 'friends: richer GetFriendList payload + logging' (#15) from preprod into main
Build & Deploy / build (push) Successful in 38s
2026-07-16 07:06:20 +00:00
neckfire e5ab911dfd Merge pull request 'fortuna: FortunaPass2_Rewards catalog (claimable tiers)' (#14) from preprod into main
Build & Deploy / build (push) Successful in 34s
2026-07-15 20:17:37 +00:00
neckfire 042f806697 Merge pull request 'docs: rewrite main README (dev + CI + client switch tool)' (#13) from preprod into main
Build & Deploy / build (push) Successful in 32s
2026-07-15 11:54:39 +00:00
neckfire a4c8cf5955 Merge pull request 'admin: fix empty tables (DT engine ordering)' (#12) from preprod into main
Build & Deploy / build (push) Successful in 33s
2026-07-15 09:52:00 +00:00
neckfire d1f3fd1125 Merge pull request 'admin: interactive tables (sort/filter/search/paging)' (#11) from preprod into main
Build & Deploy / build (push) Successful in 31s
2026-07-15 09:31:21 +00:00
neckfire b05782d4e9 Merge pull request 'Admin: noms lisibles + catalogue (gameref)' (#10) from preprod into main
Build & Deploy / build (push) Successful in 29s
2026-07-15 09:19:14 +00:00
neckfire d7c89327a7 Merge pull request 'Steam persona display names + admin backfill' (#9) from preprod into main
Build & Deploy / build (push) Successful in 29s
2026-07-15 08:25:53 +00:00
neckfire 03048b793d Merge pull request 'Back-office admin /admin (LAN-only)' (#8) from preprod into main
Build & Deploy / build (push) Successful in 32s
2026-07-15 07:56:34 +00:00
neckfire 1d9cf795dd Merge pull request 'Audit léger: stats carrière, présence amis, AddGenericId' (#7) from preprod into main
Build & Deploy / build (push) Successful in 27s
2026-07-15 07:02:46 +00:00
neckfire 7dbab1aab8 Merge pull request 'Audit fixes: Fortuna pass level, rename persist, loadout & craft-timer' (#6) from preprod into main
Build & Deploy / build (push) Successful in 26s
2026-07-15 00:57:11 +00:00
neckfire 078d2f80be Merge pull request 'Fix critique: achats bloqués (sentinelle ContractLockPurchase None)' (#5) from preprod into main
Build & Deploy / build (push) Successful in 24s
2026-07-15 00:15:14 +00:00
neckfire c986a98471 Merge pull request 'Promotion: fix sauvegarde des missions en zone (raid client-hosted)' (#4) from preprod into main
Build & Deploy / build (push) Successful in 23s
2026-07-14 23:58:55 +00:00
neckfireandClaude Opus 4.8 f0343bc1ea gameserver: consolidate netcode R&D — remove diag spam, document DTLS-PSK wall
Build Game Server / build (push) Successful in 21s
- Keep the R3.5.0 header-bit fix (bunch alignment breakthrough)
- Remove per-packet [HS-RAW]/[HS-HDR]/[HS-BUNCH] diagnostics
- Document the DTLS-PSK encryption wall + packed-binary finding in NETCODE-RND.md
- UWorld NMT_Hello: honest comment on why the connection stops here

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:13:03 +02:00
neckfireandClaude Opus 4.8 ba9976c5b6 gameserver: skip encryption on Hello (try plaintext challenge)
Build Game Server / build (push) Successful in 19s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:54:01 +02:00
neckfireandClaude Opus 4.8 fb73038901 gameserver: consume Cycle's extra header bit -> bunches align (NMT_Hello parses)
Build Game Server / build (push) Successful in 22s
Bit-decoded real client packets: the header is 65 bits (not stock UE 4.27's 64).
One extra bit sits between the ack history and the packet-info payload; consuming
it realigns bHasPacketInfoPayload + jitter + bHasServerFrameTime, and the first
control-channel bunch parses (ChIndex 0, open, reliable = NMT_Hello).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:50:19 +02:00
neckfireandClaude Opus 4.8 629e8a4c31 gameserver: dump full packet bits (512) to decode NMT_Hello netver
Build Game Server / build (push) Successful in 18s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:29:11 +02:00
neckfireandClaude Opus 4.8 1691af4be6 gameserver: dump raw packet bit-stream for hand-decode
Build Game Server / build (push) Successful in 18s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:53:53 +02:00
neckfireandClaude Opus 4.8 b1386bef56 gameserver: bit-position diagnostics in ReceivedPacket (header + bunch flags)
Build Game Server / build (push) Successful in 17s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:41:39 +02:00
neckfireandClaude Opus 4.8 71aca5e3d0 fix(game-server): survive bad-packet exceptions in tick loop
Build Game Server / build (push) Successful in 17s
Wrap world.Tick in try/catch so an incompatible/misaligned client packet
logs an error and the server keeps running (stable R&D bench).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:30:12 +02:00
neckfireandClaude Opus 4.8 4d2280f8ab fix(game-server): don't crash on malformed bunch channel index
Build Game Server / build (push) Successful in 16s
Log + drop the packet (with net version) instead of throwing, so a
bit-alignment/version mismatch with the client doesn't kill the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:26:53 +02:00
neckfireandClaude Opus 4.8 1caa46f846 fix(game-server): adopt client packet sequences on first packet
Build Game Server / build (push) Successful in 16s
The Cycle client does not derive its initial packet sequences from the
handshake cookie the way stock UE does (its acked server-seq isn't present
anywhere in the cookie), so the cookie-based InitSequence disagreed and
every post-handshake packet was rejected as out-of-order. On the first
packet, adopt the client's announced Seq/AckedSeq instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:21:49 +02:00
neckfireandClaude Opus 4.8 9092dda950 diag(game-server): log packet sequence mismatch (post-handshake)
Build Game Server / build (push) Successful in 21s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:08:52 +02:00
neckfireandClaude Opus 4.8 6ccad507a8 diag(game-server): log connectionless handshake (challenge/response)
Build Game Server / build (push) Successful in 16s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:00:19 +02:00
neckfireandClaude Opus 4.8 f4d1757840 diag(game-server): log stateless handshake packet size vs expected
Build Game Server / build (push) Successful in 15s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:52:52 +02:00
neckfireandClaude Opus 4.8 9749828af8 feat(game-server): scaffold dedicated server toward player join
Build Game Server / build (push) Successful in 17s
R&D branch for an authoritative Unreal game server (Prospect.Unreal).
The connection handshake already reaches PlayerController spawn; this
commit pushes it toward real players joining:

- Program.cs: target the Cycle station map + game mode (configurable via
  PROSPECT_MAP/GAMEMODE/PORT), proper host loop, and initialise a
  GameSession so NMT_Join -> Login no longer fails ("GameSession is null").
- UWorld.WelcomePlayer: send the world's real map/game mode to the client
  instead of the UE ThirdPerson template.
- Dockerfile.gameserver + .gitea/workflows/game-server.yml: build/push a
  separate image (the-cycle-game) on the game-server branch, ntfy notify.
- GAMESERVER.md: honest state + roadmap (player spawn + movement
  replication need live client testing; AI/loot intentionally deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:30:09 +02:00
neckfire 57a45d91df Merge pull request 'Social prod : liste d amis + fix ping 500' (#3) from preprod into main
Build & Deploy / build (push) Successful in 15s
2026-07-14 13:29:45 +00:00
neckfire c6b2330e1d docs: pointer vers la release pour LoaderPack + cert
Build & Deploy / build (push) Successful in 16s
2026-07-14 13:09:57 +00:00
neckfire 9737316dbc docs: ajoute la commande download_depot + astuce disque
Build & Deploy / build (push) Successful in 19s
2026-07-14 13:07:35 +00:00
neckfireandClaude Opus 4.8 a4e006f84f docs: guide d installation client pour les amis (serveur prive)
Build & Deploy / build (push) Successful in 15s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:33:37 +00:00
neckfire 3cdf8bc7e2 Merge pull request 'Boutiques : GetStoreItems (vendeurs + Aurum) + fix vanities possédées' (#2) from preprod into main
Build & Deploy / build (push) Successful in 13s
2026-07-14 12:07:18 +00:00
neckfire 67f3095896 Merge pull request 'Promotion preprod → prod : contrats/kills, factions, tech-tree, assurance payout, Fortuna Pass' (#1) from preprod into main
Build & Deploy / build (push) Successful in 11s
2026-07-14 10:33:31 +00:00
17 changed files with 1180 additions and 34 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Build Game Server
on:
push:
branches: [game-server]
workflow_dispatch: {}
env:
IMAGE: git.nfteam.ovh/neckfire/the-cycle-game
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build & push image
run: |
set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
SHA="${GITHUB_SHA::12}"
docker build -t "${IMAGE}:game-server" -t "${IMAGE}:${SHA}" -f Dockerfile.gameserver .
docker push --all-tags "${IMAGE}"
- name: Notify ntfy
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then EMOJI="white_check_mark"; PRIO="default"; else EMOJI="rotating_light"; PRIO="high"; fi
curl -s -H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" -H "Title: ${GITHUB_REPOSITORY} game-server — ${{ job.status }}" \
-H "Priority: ${PRIO}" -H "Tags: ${EMOJI}" -H "Click: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions" \
-d "${GITHUB_WORKFLOW} (${GITHUB_REF_NAME} #${GITHUB_RUN_NUMBER}) : ${{ job.status }}" \
"${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true
+14
View File
@@ -0,0 +1,14 @@
# The Cycle: Frontier — serveur de jeu dédié (EXPÉRIMENTAL / R&D).
# Réimplémentation du serveur autoritaire Unreal (Prospect.Unreal). Build depuis les sources.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY src/ ./src/
RUN dotnet publish src/Prospect.Server.Game/Prospect.Server.Game.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/runtime:8.0
WORKDIR /app
COPY --from=build /app ./
# Le réseau Unreal est en UDP.
EXPOSE 7777/udp
# Configurable : PROSPECT_MAP / PROSPECT_GAMEMODE / PROSPECT_PORT
ENTRYPOINT ["dotnet", "Prospect.Server.Game.dll"]
+62
View File
@@ -0,0 +1,62 @@
# Serveur de jeu dédié — R&D (branche `game-server`)
> ⚠️ **Expérimental.** Objectif : un serveur de jeu **autoritaire** pour que plusieurs
> joueurs soient dans la **même instance** (se voir, bouger). C'est un chantier de
> reverse-engineering du serveur Unreal du Cycle. **Un raid co-op complet reste hors de portée
> réaliste** ; on avance par jalons. **IA et loot volontairement hors périmètre pour l'instant.**
## Pièces en jeu
- **`Prospect.Unreal`** — réimplémentation en C# de la couche réseau d'Unreal Engine
(NetDriver UDP, channels control/actor, bunches, packet handler, handshake, `UWorld`,
`AGameModeBase`/`APlayerController`/`APawn`).
- **`Prospect.Server.Game`** — l'exécutable serveur (host loop, monde, game mode).
## État actuel (ce qui marche côté serveur)
Le **handshake de connexion Unreal est implémenté** et va jusqu'au spawn du PlayerController :
```
NMT_Hello → SendChallenge
NMT_Login → PreLogin → WelcomePlayer (envoie map + game mode)
NMT_Join → SpawnPlayActor → GameMode.Login → APlayerController
```
Corrections/avancées de cette branche :
- **Cible la map/gamemode du Cycle** (`/Game/Maps/MP/Station/Station_P` + `YGameMode_Station`)
au lieu de la map template d'UE. Configurable via `PROSPECT_MAP` / `PROSPECT_GAMEMODE` / `PROSPECT_PORT`.
- **`WelcomePlayer`** envoie désormais la **vraie** map/gamemode du monde (plus le template).
- **`GameSession`** est initialisée → le login ne plante plus sur `"GameSession is null"`
(c'était le point de blocage juste avant le spawn).
## Ce qui manque (roadmap, du plus atteignable au plus dur)
1. **Connexion client réelle** : valider le handshake complet avec le **vrai client** (pas le
harnais `Client.cs`). Nécessite des **tests en live** (impossible à valider hors client).
2. **Spawn du Pawn du Cycle** : `GameMode.Login` spawn un `APlayerController` mais **pas** le
personnage. Il faut spawner la **classe de Pawn spécifique du Cycle** (`YCharacter…`) avec le
bon **NetGUID / class path** pour que le client l'instancie.
3. **Réplication du mouvement** : répliquer les propriétés du `CharacterMovementComponent`
(position/rotation/état) chaque tick → **le premier vrai « se voir bouger »**.
4. *(plus tard)* IA, loot, dégâts, tempête, évac… — **hors périmètre pour l'instant**.
Les jalons 23 demandent de connaître les **classes répliquées du jeu** (côté client, non
présentes dans le code serveur) et **itèrent en live** avec le client. C'est le vrai mur.
## Build / run
```bash
# build
dotnet build src/Prospect.Server.Game/Prospect.Server.Game.csproj -c Release
# run (défauts : station, port 7777 UDP)
dotnet run --project src/Prospect.Server.Game
# ou conteneur
docker build -t the-cycle-game -f Dockerfile.gameserver .
docker run --rm -p 7777:7777/udp the-cycle-game
```
## CI/CD
`.gitea/workflows/game-server.yml` : à chaque push sur `game-server`, build de `Dockerfile.gameserver`
→ image **`git.nfteam.ovh/neckfire/the-cycle-game`** (tags `game-server` + sha) + notif ntfy.
Image **séparée** de l'API (`the-cycle`) — les deux ne se marchent pas dessus.
## Honnêteté
Ceci est une **base d'exploration**. Le handshake + le spawn du controller avancent ; le
« 2 joueurs se voient bouger » dépend du spawn du pawn du Cycle + réplication, qui exige du RE
spécifique au jeu **et** des tests dans le client réel. Aucune garantie d'aboutir.
+219
View File
@@ -0,0 +1,219 @@
# 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=<JWT>?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/<pid>/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/<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).
+48 -20
View File
@@ -1,4 +1,5 @@
using Prospect.Unreal.Core;
using Prospect.Unreal.Core;
using Prospect.Unreal.Net.Actors;
using Prospect.Unreal.Runtime;
using Serilog;
@@ -7,10 +8,10 @@ namespace Prospect.Server.Game;
internal static class Program
{
private const float TickRate = (1000.0f / 60.0f) / 1000.0f;
private static readonly ILogger Logger = Log.ForContext(typeof(Program));
private static readonly PeriodicTimer Tick = new PeriodicTimer(TimeSpan.FromSeconds(TickRate));
public static async Task Main()
{
Console.CancelKeyPress += (_, e) =>
@@ -18,37 +19,64 @@ internal static class Program
Tick.Dispose();
e.Cancel = true;
};
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Verbose()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] ({SourceContext,-52}) {Message:lj}{NewLine}{Exception}")
.CreateLogger();
Logger.Information("Starting Prospect.Server.Game");
// Prospect:
// Map: /Game/Maps/MP/Station/Station_P
// GameMode: /Script/Prospect/YGameMode_Station
var worldUrl = new FUrl
{
Map = "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap"
};
// The Cycle: Frontier authoritative game server.
// We start on the station map (the simplest shared space to get two players spawned
// and moving); the raid maps (Bright Sands, …) come once spawn + movement replication
// work end to end. AI and loot are intentionally out of scope for now.
var map = Environment.GetEnvironmentVariable("PROSPECT_MAP") ?? "/Game/Maps/MP/Station/Station_P";
var gameMode = Environment.GetEnvironmentVariable("PROSPECT_GAMEMODE") ?? "/Script/Prospect/YGameMode_Station";
var port = int.TryParse(Environment.GetEnvironmentVariable("PROSPECT_PORT"), out var p) ? p : 7777;
Logger.Information("Starting Prospect.Server.Game — map={Map} gameMode={GameMode} port={Port}", map, gameMode, port);
var worldUrl = new FUrl { Map = map, Port = port };
worldUrl.Options.Add($"game={gameMode}");
await using (var world = new ProspectWorld())
{
world.SetGameInstance(new UGameInstance());
world.SetGameMode(worldUrl);
// The game mode needs a GameSession, otherwise NMT_Join -> SpawnPlayActor -> Login
// fails with "GameSession is null" and the player is never spawned.
var authGameMode = world.GetAuthGameMode();
if (authGameMode != null && authGameMode.GameSession == null)
{
authGameMode.GameSession = new AGameSession();
}
world.InitializeActorsForPlay(worldUrl, true);
world.Listen();
if (!world.Listen())
{
Logger.Fatal("Failed to start listening (port {Port} already in use?)", port);
return;
}
Logger.Information("Server listening on :{Port}. Waiting for players…", port);
while (await Tick.WaitForNextTickAsync())
{
world.Tick(TickRate);
try
{
world.Tick(TickRate);
}
catch (Exception ex)
{
// A misaligned/incompatible client packet must not kill the whole server
// (R&D: the client's exact net version isn't matched yet). Log and continue.
Logger.Error(ex, "Tick error (bad packet?) — continuing");
}
}
}
Logger.Information("Shutting down");
}
}
}
@@ -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();
}
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())
{
RealignPacket(processPacketReader);
@@ -129,19 +129,22 @@ public class FNetPacketNotify
{
if (!notificationData.Seq.Greater(_inSeq))
{
Logger.Information("[HS-SEQ] fail1 seq<=inSeq seq={Seq} inSeq={InSeq}", notificationData.Seq.Value, _inSeq.Value);
return 0;
}
if (!notificationData.AckedSeq.GreaterEq(_outAckSeq))
{
Logger.Information("[HS-SEQ] fail2 ackedSeq<outAckSeq ackedSeq={Acked} outAckSeq={OutAck}", notificationData.AckedSeq.Value, _outAckSeq.Value);
return 0;
}
if (!_outSeq.Greater(notificationData.AckedSeq))
{
Logger.Information("[HS-SEQ] fail3 outSeq<=ackedSeq outSeq={OutSeq} ackedSeq={Acked}", _outSeq.Value, notificationData.AckedSeq.Value);
return 0;
}
return SequenceNumber.Diff(notificationData.Seq, _inSeq);
}
@@ -185,13 +185,20 @@ public class StatelessConnectHandlerComponent : HandlerComponent
public override void Incoming(FBitReader packet)
{
var diagTotalBits = packet.GetBitsLeft();
if (_magicHeader.Length > 0)
{
// Skip magic header.
packet.Pos += _magicHeader.Length;
}
var bHandshakePacket = packet.ReadBit() && !packet.IsError();
// [HS-DIAG] Compare the incoming packet against what the stateless handshake expects.
// The Cycle client may use a magic header / different handshake size than 227 bits.
Logger.Information("[HS-DIAG] Incoming totalBits={Total} magicLen={Magic} bHandshake={HS} bitsLeftAfterFlag={Left} expectAfterFlag={Exp}",
diagTotalBits, _magicHeader.Length, bHandshakePacket, packet.GetBitsLeft(), HandshakePacketSizeBits - 1);
if (bHandshakePacket)
{
var bRestartHandshake = false;
@@ -392,9 +399,10 @@ public class StatelessConnectHandlerComponent : HandlerComponent
}
var bHandshakePacket = packet.ReadBit() && !packet.IsError();
Logger.Information("[HS-DIAG-CL] Incoming flag={HS} bitsLeftAfterFlag={Left} expect={Exp}", bHandshakePacket, packet.GetBitsLeft(), HandshakePacketSizeBits - 1);
_lastChallengeSuccessAddress = null;
if (bHandshakePacket)
{
var bRestartHandshake = false;
@@ -404,6 +412,7 @@ public class StatelessConnectHandlerComponent : HandlerComponent
Span<byte> origCookie = stackalloc byte[CookieByteSize];
bHandshakePacket = ParseHandshakePacket(packet, ref bRestartHandshake, ref secretId, ref timestamp, cookie, origCookie);
Logger.Information("[HS-DIAG-CL] parsed ok={Ok} restart={R} secretId={S} timestamp={T}", bHandshakePacket, bRestartHandshake, secretId, timestamp);
if (bHandshakePacket)
{
@@ -412,6 +421,7 @@ public class StatelessConnectHandlerComponent : HandlerComponent
var bInitialConnect = timestamp == 0.0;
if (bInitialConnect)
{
Logger.Information("[HS-DIAG-CL] initial -> SendConnectChallenge to {Addr}", address);
SendConnectChallenge(address);
}
else if (_driver != null)
@@ -430,6 +440,7 @@ public class StatelessConnectHandlerComponent : HandlerComponent
GenerateCookie(address, secretId, timestamp, regenCookie);
bChallengeSuccess = cookie.SequenceEqual(regenCookie);
Logger.Information("[HS-DIAG-CL] challenge response cookieMatch={Ok}", bChallengeSuccess);
if (bChallengeSuccess)
{
+60 -3
View File
@@ -219,7 +219,16 @@ public abstract class UNetConnection : UPlayer
/// Reference to the PacketHandler component, for managing stateless connection handshakes
/// </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>Composant de chiffrement AES-256-ECB (conforme FAESHandlerComponent d'UE), keyé par la PSK.</summary>
public AesHandlerComponent? AesComponent { get; private set; }
/// <summary>
/// Net id of remote player on this connection. Only valid on client connections (server side).
/// </summary>
@@ -267,6 +276,8 @@ public abstract class UNetConnection : UPlayer
/// <summary>
/// Full incoming packet index.
/// </summary>
private bool _bAdoptedInitialSequence;
public int InPacketId { get; private set; }
/// <summary>
@@ -498,7 +509,7 @@ public abstract class UNetConnection : UPlayer
var resetReaderMark = new FBitReaderMark(reader);
var channelsToClose = new List<FChannelCloseInfo>();
if (_bInternalAck)
{
++InPacketId;
@@ -515,6 +526,19 @@ public abstract class UNetConnection : UPlayer
return;
}
// The Cycle client (UE4 R3.5.0) writes one extra bit between the packet's
// ack history and the packet-info payload that stock UE 4.27 (EngineNetVer
// 16) does not. Bit-decoding real client packets showed the header is 65
// bits, not 64: consuming this bit realigns everything downstream — the
// bHasPacketInfoPayload flag, the 10-bit jitter clock and bHasServerFrameTime
// then land exactly, and the first control-channel bunch parses cleanly
// (ChIndex 0, NMT_Hello). Observed always 0 in captures.
var bCycleExtraHeaderBit = reader.ReadBit();
if (bCycleExtraHeaderBit)
{
Logger.Warning("[HS-HDR] Cycle extra header bit was set (expected 0)");
}
var bHasPacketInfoPayload = true;
if (reader.EngineNetVer() > EEngineNetworkVersionHistory.HISTORY_JITTER_IN_HEADER)
@@ -540,6 +564,25 @@ public abstract class UNetConnection : UPlayer
}
var packetSequenceDelta = PacketNotify.GetSequenceDelta(header);
// The Cycle client doesn't derive its initial packet sequences from the handshake
// cookie the way stock UE does, so our cookie-based InitSequence disagrees with it
// and every packet looks out-of-order. On the very first packet, adopt the client's
// announced sequences (Seq + AckedSeq) instead of the cookie-derived ones.
if (packetSequenceDelta <= 0 && !_bAdoptedInitialSequence && Driver != null && Driver.IsServer())
{
_bAdoptedInitialSequence = true;
var adoptIn = new SequenceNumber((ushort)(header.Seq.Value - 1));
var adoptOut = new SequenceNumber((ushort)(header.AckedSeq.Value + 1));
PacketNotify.Init(adoptIn, adoptOut);
InPacketId = header.Seq.Value - 1;
OutPacketId = header.AckedSeq.Value + 1;
OutAckPacketId = header.AckedSeq.Value;
LastNotifiedPacketId = OutAckPacketId;
Logger.Information("[HS-SEQ] Adopted client sequences: inSeq={In} outSeq={Out}", header.Seq.Value, header.AckedSeq.Value + 1);
packetSequenceDelta = PacketNotify.GetSequenceDelta(header);
}
if (packetSequenceDelta > 0)
{
var bPacketOrderCacheActive = !_bFlushingPacketOrderCache && _packetOrderCache != null;
@@ -645,7 +688,10 @@ public abstract class UNetConnection : UPlayer
if (bunch.ChIndex >= MaxChannelSize)
{
throw new Exception("Bunch channel index exceeds channel limit");
// Don't crash the whole server on a malformed/misaligned bunch (e.g. a
// version/bit-alignment mismatch with the client). Log and drop the packet.
Logger.Error("[HS-SEQ] Bunch channel index {Idx} exceeds limit {Max} (netVer={Ver}) — dropping packet", bunch.ChIndex, MaxChannelSize, (int)bunch.EngineNetVer());
return;
}
}
@@ -1079,6 +1125,7 @@ public abstract class UNetConnection : UPlayer
public void InitSequence(int incomingSequence, int outgoingSequence)
{
Logger.Information("[HS-SEQ] InitSequence incoming={In} outgoing={Out}", incomingSequence, outgoingSequence);
if (InPacketId == -1)
{
// Initialize the base UNetConnection packet sequence (not very useful/effective at preventing attacks)
@@ -1129,6 +1176,16 @@ 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>();
// 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();
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>
+65 -7
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;
@@ -286,7 +290,60 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
}
else
{
throw new NotImplementedException("Encryption");
// R&D WALL — The Cycle mandates encryption on the game connection.
// The client's NMT_Hello carries EncryptionToken = its PlayFab
// user_id (e.g. "92EBCFE8C3EAF3AC"), and the client PacketHandler
// stack is [DTLSHandlerComponent, StatelessConnectHandlerComponent]
// (confirmed from the client's own logs). Encryption is therefore
// DTLS in PSK mode (the shipping exe bundles ECDHE/DHE-PSK-AES256
// cipher suites + a "DTLS.PreSharedKeys" cvar + DTLSPSK*Callback).
//
// Proceeding to the challenge WITHOUT sending NMT_EncryptionAck does
// NOT work: the client requires encryption and closes the control
// channel immediately after the plaintext challenge.
//
// To finish this we'd need (1) a DTLS-PSK server that matches UE's
// DTLSHandlerComponent framing, and (2) the 32-byte PSK the client
// derives per user_id. The derivation lives in the client's code,
// which cannot be recovered statically: the exe is packed/encrypted
// (.text entropy = 8.0), so Ghidra/radare2 see only ciphertext. The
// only route left is a runtime memory dump of the decrypted image.
//
// For now: log and proceed to the challenge so the flow is visible
// in logs; the client will close afterwards.
// 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();
}
}
}
break;
@@ -434,13 +491,14 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
private void WelcomePlayer(UNetConnection connection)
{
// TODO: Properly fetch level name from CurrentLevel
var levelName = "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap";
// TODO: Properly fetch from AuthorityGameMode
var gameName = "/Script/ThirdPersonMP.ThirdPersonMPGameMode";
// Tell the client which level + game mode to travel to. Previously hardcoded to the
// UE ThirdPerson template; now use the world's configured map and the "game=" option
// (set by the host), falling back to the template if unset.
var levelName = string.IsNullOrEmpty(Url.Map) ? "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap" : Url.Map;
var gameName = Url.GetOption("game=", "/Script/ThirdPersonMP.ThirdPersonMPGameMode") ?? string.Empty;
var redirectUrl = string.Empty;
Logger.Information("Welcoming player -> level={Level} game={Game}", levelName, gameName);
NMT_Welcome.Send(connection, levelName, gameName, redirectUrl);
connection.FlushNet();