minecraft_protocol: foundation + per-version protocol docs 1.7.10->26.2

8 topical docs (overview, data types, lifecycle, handshake, status/ping,
login+encryption, configuration, version-differences) + proxy-forwarding set
+ 16 per-version release-line docs, sourced from minecraft.wiki, ViaVersion
(source + commits), minecraft-data, node-minecraft-protocol, Velocity, BungeeCord.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude-timemachine
2026-06-19 14:15:32 +02:00
commit d73c1c9537
35 changed files with 8894 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# Proxy Forwarding Modes
How a Minecraft proxy moves a player between backend servers — and how it securely tells each backend *who the player is*.
This is the most load-bearing section of the reference. Get forwarding wrong and you either (a) break logins, or (b) open your network to anyone connecting as anyone.
## What a proxy does
A Minecraft proxy (BungeeCord, Waterfall, [Velocity](https://github.com/PaperMC/Velocity)) sits in front of *N* backend servers (lobby, survival, minigames, modded, …). The player's client connects **once** to the proxy. The proxy speaks the full client-facing protocol — handshake, login, encryption, online-mode auth against Mojang — and then opens its *own* connection to a backend server and pumps packets between the two. When the player runs `/server survival`, the proxy silently tears down the backend connection and opens a new one to a different backend, all without the client reconnecting.
```mermaid
sequenceDiagram
autonumber
actor C as "Client (player)"
participant P as "Proxy (Velocity/BungeeCord)"
participant L as "Backend: lobby (offline-mode)"
participant S as "Backend: survival (offline-mode)"
C->>P: "connect once (online-mode auth here)"
P->>L: "open backend conn + forward identity"
L-->>C: "play (via proxy relay)"
Note over C,S: "player runs /server survival"
P->>S: "open new backend conn + forward identity"
S-->>C: "play (via proxy relay)"
```
## Why forwarding exists
The proxy already authenticated the player against Mojang. The **backends must not** repeat that — they run in **offline mode** (`online-mode=false`), because:
- A backend in online mode would demand its *own* Mojang session handshake from the connecting party (the proxy), which the proxy can't satisfy on the player's behalf.
- Running backends offline lets the proxy own a single client connection and freely re-point it.
But an offline-mode server, by itself, knows nothing real about who connected. It only sees a username string in `LoginStart`, and it derives an **offline UUID** from that name (see [online-offline-modes.md](online-offline-modes.md)). So the proxy must *forward* the player's real identity to the backend:
- real **client IP** (so the backend sees the player, not the proxy)
- real **UUID** (the Mojang account UUID for premium players)
- **username**
- **game-profile properties** — the `textures` property (skin/cape) and, on newer versions, the player's signature/public key
And — critically — the backend must be able to **trust** that this forwarded identity actually came from the proxy and not from an attacker who found the backend's port. That trust mechanism is what distinguishes the modes.
## The menu of modes
| Mode | Doc | One-line |
|---|---|---|
| **none** | [online-offline-modes.md](online-offline-modes.md) | No forwarding. Backend uses the raw `LoginStart` name → offline UUID. Only safe if the backend is the edge. |
| **legacy / bungeecord** | [bungeecord-legacy.md](bungeecord-legacy.md) | Proxy stuffs `ip\0uuid\0properties` into the handshake address field. **No crypto.** |
| **bungeeguard** | [bungeeguard.md](bungeeguard.md) | Legacy + a shared secret token smuggled in the forwarded properties. Third-party. |
| **modern / velocity** | [velocity-modern.md](velocity-modern.md) | Backend requests identity via a Login Plugin message; proxy replies with an **HMAC-SHA256-signed** payload. Gold standard. |
## Comparison
| Mode | Crypto? | Backend support needed | Spoofable if backend port exposed? | Forge-friendly |
|---|---|---|---|---|
| **none** | — | none (default offline behavior) | **Yes** — anyone can claim any name | n/a |
| **legacy (BungeeCord)** | None | Spigot/Paper `settings.bungeecord: true` (or Fabric equiv.) | **Yes** — forge the `\0`-delimited string | Yes — `\0FML\0` marker rides in the handshake (see [forge-fml.md](forge-fml.md)) |
| **bungeeguard** | Shared secret (plaintext token, compared by the backend plugin) | Legacy forwarding **+** BungeeGuard plugin on the backend | **No** (attacker lacks the token), *but* the token travels in plaintext inside the connection | Yes — same handshake-field transport as legacy |
| **modern (Velocity)** | **HMAC-SHA256** over the payload, keyed by a shared secret | Paper `velocity.enabled`+`velocity.secret` (or Fabric/Forge mod that implements it) | **No** — attacker can't produce a valid HMAC without the secret | Needs care — see [forge-fml.md](forge-fml.md) (the FML handshake competes with the login-plugin channel) |
**Rule of thumb**: use **modern/Velocity** when every backend supports it; fall back to **bungeeguard** when a backend only speaks legacy; use bare **legacy** only inside a network where the backend ports are firewalled so *only the proxy* can reach them; use **none** only when the server is itself the internet edge in online mode.
> The "exposed port" risk is the whole point. With legacy and none, your only defense is the firewall — bind backends to localhost / a private interface and let only the proxy connect. With bungeeguard and modern, the protocol itself rejects forged identities. See [online-offline-modes.md](online-offline-modes.md#security-model) for why offline backends are open by default.
## Mapping to the automc platform
In the automc stack, each backend Minecraft server runs with a **Velocity sidecar** managed by `mc-wrapper` (the wrapper runs the proxy alongside the MC server in the same pod). `mc-wrapper`'s `PROXY_MODE` concept selects which of these forwarding modes the sidecar↔server pair uses — practically that means **modern/Velocity forwarding** (the [velocity-modern.md](velocity-modern.md) path) with a shared secret, since proxy and server are co-located and the wrapper provisions the secret. This doc set is the protocol-level reference behind that knob; the automc-specific wiring lives in the `mc-wrapper` repo, not here.
+116
View File
@@ -0,0 +1,116 @@
# BungeeCord Legacy IP Forwarding
The original forwarding scheme, introduced by BungeeCord and adopted everywhere. It works by **abusing the handshake's `serverAddress` field** — the proxy appends the player's identity to that string, null-byte delimited, and the offline-mode backend parses it back out. **No cryptography is involved.**
## The mechanism
The handshake packet (see [../03-handshake.md](../03-handshake.md)) has a `serverAddress` (a.k.a. *Server Address* / host) string — normally the hostname the client used to connect (e.g. `mc.example.com`). The vanilla server mostly ignores its content. BungeeCord repurposes it: when IP-forwarding is enabled, the proxy **rewrites** that field before opening the backend connection, packing four `\0`-separated segments into it.
The backend (a Spigot/Paper server with `settings.bungeecord: true`, or a Fabric server with an equivalent mod) recognizes the extra segments and reads the player's real IP, UUID, and properties out of them — instead of treating the whole string as a hostname.
## Exact wire format
The proxy rewrites the handshake host to:
```
realHost \0 clientIP \0 playerUUID(no dashes) \0 texturesPropertiesJSON
```
Where (in order):
| Segment | Content |
|---|---|
| `realHost` | the original handshake host the client sent (e.g. `mc.example.com`), minus any trailing FML marker |
| `clientIP` | the player's real socket IP, sanitized (brackets stripped from IPv6, scope id removed) |
| `playerUUID` | the player's UUID **with dashes removed** (32 hex chars) |
| `texturesPropertiesJSON` | a JSON array of the login profile's game-profile **properties** (the Mojang-signed `textures` skin/cape entry). **Omitted entirely** (along with its leading `\0`) if the profile has no properties — i.e. the player connected in offline/cracked mode upstream. |
This is exactly what BungeeCord writes in `ServerConnector.connected()`:
> ```java
> String newHost = copiedHandshake.getHost() + "\00" + AddressUtil.sanitizeAddress( user.getAddress() ) + "\00" + user.getUUID();
>
> LoginResult profile = user.getPendingConnection().getLoginProfile();
> if ( profile != null && profile.getProperties() != null && profile.getProperties().length > 0 )
> {
> newHost += "\00" + LoginResult.GSON.toJson( profile.getProperties() );
> }
> copiedHandshake.setHost( newHost );
> ```
>
> — `BungeeCord/proxy/src/main/java/net/md_5/bungee/ServerConnector.java:116-123`
(BungeeCord writes the null byte as the Java octal escape `"\00"`, i.e. a single `U+0000`.) Note the **UUID has no dashes**`user.getUUID()` returns the undashed form. The properties JSON is the serialized array of `{name, value, signature}` objects; for an online player it's the single Mojang-signed `textures` property.
Velocity, when configured in `legacy` mode, builds the **same** string — and its source documents the format verbatim:
> ```java
> // BungeeCord IP forwarding is simply a special injection after the "address" in the handshake,
> // separated by \0 (the null byte). In order, you send the original host, the player's IP, their
> // UUID (undashed), and if you are in online-mode, their login properties (from Mojang).
> final StringBuilder data = new StringBuilder()
> .append(serverAddress).append(LEGACY_SEPARATOR)
> .append(playerAddress).append(LEGACY_SEPARATOR)
> .append(profile.getUndashedId()).append(LEGACY_SEPARATOR);
> GENERAL_GSON.toJson(profile.getProperties(), data);
> ```
>
> — `Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/PlayerDataForwarding.java:160-172` (`LEGACY_SEPARATOR = '\0'`, defined at `:50`)
## How the backend parses it
The receiving side splits the host on `\0`. BungeeCord's *own* handshake handler (which is what a downstream BungeeCord-as-backend, or a Spigot `bungeecord:true` server emulating the same logic, does) splits the host and keeps the tail:
> ```java
> if ( handshake.getHost().contains( "\0" ) )
> {
> String[] split = handshake.getHost().split( "\0", 2 );
> handshake.setHost( split[0] );
> extraDataInHandshake = "\0" + split[1];
> }
> ```
>
> — `BungeeCord/proxy/src/main/java/net/md_5/bungee/connection/InitialHandler.java:355-360`
A Spigot/Paper backend with bungeecord forwarding enabled does the analogous thing: it splits the host into `[host, ip, uuid, properties]`, sets the player's address to `ip`, the UUID to the dash-inserted form of `uuid`, and the game-profile properties to the parsed JSON.
## Sequence
```mermaid
sequenceDiagram
autonumber
actor C as "Client (player)"
participant P as "Proxy (BungeeCord, ip_forward=true)"
participant B as "Backend (Spigot bungeecord=true, offline-mode)"
C->>P: "Handshake host=mc.example.com"
C->>P: "LoginStart (username)"
Note over P: "online-mode auth vs Mojang (hasJoined)"
Note over P: "rewrite host -> realHost\\0clientIP\\0uuidNoDashes\\0propsJSON"
P->>B: "Handshake host='realHost\\0IP\\0UUID\\0props'"
P->>B: "LoginStart (username, rewriteId)"
Note over B: "split host on \\0; trust IP/UUID/props as-is"
B-->>C: "LoginSuccess (via proxy relay) -> play"
```
## Security: none
There is **no signature, no secret, no verification**. The backend trusts the `\0`-delimited string completely. If an attacker can reach the backend's port, they simply send a handshake with a hand-crafted `host\0ip\0uuid\0props` string and connect as **any player they like**, with any UUID and any skin.
The *only* defense for bare legacy forwarding is the network: **firewall the backend so only the proxy can connect** (bind to localhost / a private interface; drop everything else). This is the central weakness that [BungeeGuard](bungeeguard.md) (adds a secret token to the properties) and [Velocity modern forwarding](velocity-modern.md) (HMAC-signs the whole payload) exist to fix.
Velocity even warns the operator when a legacy backend closes the connection — almost always a misconfigured `bungeecord: true`:
> *"This is usually because the remote server does not have BungeeCord IP forwarding correctly enabled."* — `Velocity/.../backend/LoginSessionHandler.java:205-212`
## Forge note
If the client is on Forge, FML appends its own `\0FML\0` (or newer `\0FML2\0` / `\0FML3\0`) marker to the handshake host. The proxy must split that off **before** injecting the forwarding segments and re-append it after, or the marker collides with the forwarding `\0` delimiters. BungeeCord handles this by stashing everything from the first `\0` as `extraDataInHandshake` (`InitialHandler.java:355-360`) and restoring it only when IP forwarding is *off* (`ServerConnector.java:124-128`). See [forge-fml.md](forge-fml.md).
---
**Sources**
- `BungeeCord/proxy/src/main/java/net/md_5/bungee/ServerConnector.java:116-123` — the WRITE of `host\0ip\0uuid\0props`.
- `BungeeCord/proxy/src/main/java/net/md_5/bungee/connection/InitialHandler.java:355-360` — the split/parse of the host on `\0`.
- `Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/PlayerDataForwarding.java:50` (`LEGACY_SEPARATOR`), `:154-173` (`createLegacyForwardingAddress`) — Velocity building the identical string, with the format documented in-comment.
- `Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/LoginSessionHandler.java:205-212` — legacy-misconfiguration diagnostic.
+69
View File
@@ -0,0 +1,69 @@
# BungeeGuard
A **hardening of [legacy forwarding](bungeecord-legacy.md)**, not a new transport. Legacy's fatal flaw is that anyone who can reach the backend port can forge the `\0`-delimited identity string and connect as anyone. BungeeGuard fixes that by smuggling a **shared secret token** into the forwarded data; the backend plugin refuses the login unless the token matches. It bridges backends that **only support legacy forwarding** up to "can't be trivially forged," without requiring [modern/Velocity](velocity-modern.md) support.
It is **third-party** — not part of Spigot/Paper core or the Mojang protocol. The original is the [BungeeGuard plugin](https://github.com/lucko/BungeeGuard) (lucko), installed on both the proxy and every backend.
## How it works
BungeeGuard reuses the exact legacy wire format — the four `\0`-separated handshake-address segments (`realHost\0clientIP\0uuidNoDashes\0propertiesJSON`). The trick is in the **properties JSON**: it appends one **synthetic game-profile property** named **`bungeeguard-token`** whose value is the shared secret.
A game-profile property is a `{name, value, signature?}` object — the same shape as the Mojang `textures` property. BungeeGuard injects an extra one:
```json
[
{ "name": "textures", "value": "...", "signature": "..." },
{ "name": "bungeeguard-token","value": "<the shared secret>" }
]
```
On the backend, the BungeeGuard plugin (replacing the stock legacy parser) reads the player's properties, finds the `bungeeguard-token` entry, and compares its value against the secret it was configured with. Match → accept and strip the token; mismatch or absent → reject the connection. The real IP/UUID/skin are still taken from the same legacy segments.
Velocity has this mode built in (`player-info-forwarding-mode = "legacy"` is bare legacy; `"bungeeguard"` adds the token). Its source builds the address identically to legacy and just adds the token property:
> ```java
> private static final String BUNGEE_GUARD_TOKEN_PROPERTY_NAME = "bungeeguard-token";
> ...
> final GameProfile.Property property = new GameProfile.Property(
> BUNGEE_GUARD_TOKEN_PROPERTY_NAME,
> new String(forwardingSecret, StandardCharsets.UTF_8),
> "");
> return createLegacyForwardingAddress(serverAddress, playerAddress, profile,
> properties -> ImmutableList.<GameProfile.Property>builder()
> .addAll(properties).add(property).build());
> ```
>
> — `Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/PlayerDataForwarding.java:52` (name) + `:175-196` (`createBungeeGuardForwardingAddress`)
So a BungeeGuard handshake address is exactly a legacy one with one extra property in the JSON array.
## Security: better than legacy, weaker than modern
| | bare legacy | BungeeGuard | modern (Velocity) |
|---|---|---|---|
| Forge-able by anyone reaching the port | **Yes** | No (needs the token) | No (needs the secret) |
| Secret/token crosses the wire | n/a | **Yes — token sent in cleartext** inside the connection | No (only an HMAC crosses) |
| Tamper-proofs IP/UUID/props | No | **No** — only gate-keeps; the identity fields are still unsigned plaintext | **Yes** — whole payload is HMAC'd |
| Backend requirement | core `bungeecord:true` | BungeeGuard plugin | Paper/Fabric modern support |
The crucial limitation: BungeeGuard is **authentication of the connection, not authentication of the payload**. It proves "whoever sent this knows the token," but the IP/UUID/properties themselves are not signed — once the token check passes, those fields are trusted as-is, exactly like legacy. And the token is transmitted **in cleartext** in the handshake. So:
- If the connection between proxy and backend is **not** encrypted/private, a network observer can lift the token and then forge freely.
- The token is a single shared value across all backends; leaking it from any one host compromises all.
It is a real improvement over bare legacy (you no longer rely solely on a firewall) and the right choice for a backend that **cannot** do modern forwarding. But when every backend supports it, **[modern/Velocity forwarding](velocity-modern.md) is strictly stronger** — it signs the payload with an HMAC and never puts the secret on the wire.
## When to use it
- A backend (old Spigot, a plugin platform, a Fabric server without modern-forward support) only speaks **legacy**, and you can't firewall it tightly enough to trust bare legacy.
- You're migrating a BungeeCord/Waterfall network and want hardening without switching every backend to Velocity-native forwarding.
Otherwise prefer modern. See the [README comparison table](README.md#comparison).
---
**Sources**
- `Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/PlayerDataForwarding.java:52` (`bungeeguard-token` property name), `:175-196` (`createBungeeGuardForwardingAddress` — legacy address + token property), `:154-173` (shared `createLegacyForwardingAddress` it builds on).
- [lucko/BungeeGuard](https://github.com/lucko/BungeeGuard) — the original third-party plugin (token property + backend check). <!-- VERIFY: plugin-side check logic from upstream README, not read from cloned source -->
- See [bungeecord-legacy.md](bungeecord-legacy.md) for the underlying `\0`-delimited wire format BungeeGuard extends.
+99
View File
@@ -0,0 +1,99 @@
# Forge / FML and Proxies
Forge (via FML — Forge Mod Loader) changes the handshake in a way that **collides with legacy forwarding**, because both want to use the same field: the handshake **Server Address** string. A proxy in front of modded backends has to understand and preserve the FML markers, or modded clients fail to connect.
## The FML handshake markers
Since FML 1.8, a Forge client **appends a token to the handshake host** so a Forge server can detect that the client is modded. The marker is a `\0`-delimited suffix:
| Marker | Era |
|---|---|
| `\0FML\0` | FML 1.8+ (legacy Forge, ~1.81.12) |
| `\0FML2\0` | newer FML (1.13+ "new" Forge networking) |
| `\0FML3\0` | later Forge revisions |
So a Forge client's handshake host looks like `mc.example.com\0FML\0` instead of plain `mc.example.com`. Both BungeeCord and Velocity hard-code the legacy token:
> ```java
> // BungeeCord
> public static final String FML_TAG = "FML";
> public static final String FML_HANDSHAKE_TAG = "FML|HS";
> public static final String FML_HANDSHAKE_TOKEN = "\0FML\0"; // "The FML 1.8 handshake token."
> ```
>
> — `BungeeCord/proxy/.../forge/ForgeConstants.java:13,14,20`
> ```java
> // Velocity (legacy Forge)
> public static final String HANDSHAKE_HOSTNAME_TOKEN = "\0FML\0";
> public static final String FORGE_LEGACY_HANDSHAKE_CHANNEL = "FML|HS";
> ```
>
> — `Velocity/proxy/.../forge/legacy/LegacyForgeConstants.java:29,34`
The deeper mod-list negotiation then happens over the `FML|HS` plugin-message channel (legacy) or modern Forge login plugin messages — that's a separate, larger handshake. The `\0FML\0` marker is just the "I am modded" flag riding in the address field.
## The collision with legacy forwarding
[Legacy/BungeeCord forwarding](bungeecord-legacy.md) **also** packs data into the handshake host (`realHost\0clientIP\0uuid\0props`), `\0`-delimited. If a Forge client adds `\0FML\0` and the proxy then naively appends `\0IP\0UUID\0props`, the two collide — the backend can't tell which `\0` segment is which.
Proxies solve this by **splitting the FML tail off first**, doing their own thing, then restoring it. BungeeCord stashes everything from the first `\0` as `extraDataInHandshake`:
> ```java
> // Starting with FML 1.8, a "\0FML\0" token is appended to the handshake. This interferes
> // with Bungee's IP forwarding, so we detect it, and remove it from the host string, for now.
> if ( handshake.getHost().contains( "\0" ) )
> {
> String[] split = handshake.getHost().split( "\0", 2 );
> handshake.setHost( split[0] );
> extraDataInHandshake = "\0" + split[1];
> }
> ```
>
> — `BungeeCord/proxy/.../connection/InitialHandler.java:355-360`
When IP forwarding is **off**, BungeeCord re-appends that saved tail so the modded backend still sees its `\0FML\0`:
> `copiedHandshake.setHost( copiedHandshake.getHost() + user.getExtraDataInHandshake() );`
> — `BungeeCord/proxy/.../ServerConnector.java:124-128`
But when IP forwarding is **on**, the FML tail can't be reattached — the forwarding segments already own the field. BungeeCord's own code marks this as a known gap (`// TODO: Add support for this data with IP forwarding.`, `ServerConnector.java:127`). Velocity is explicit about the same conflict in **legacy** mode and works around it by moving the Forge flag into a profile **property** instead of the hostname:
> ```java
> // We can't forward the FML token to the server when we are running in legacy forwarding mode,
> // since both use the "hostname" field in the handshake. We add a special property to the
> // profile instead, which will be ignored by non-Forge servers and can be intercepted by a
> // Forge coremod, such as SpongeForge.
> if (forwardingType == PlayerInfoForwarding.LEGACY) {
> return original.addProperty(IS_FORGE_CLIENT_PROPERTY);
> }
> ```
>
> — `Velocity/proxy/.../forge/legacy/LegacyForgeConnectionType.java:41-49`
## Modern Forge + Velocity (modern forwarding)
[Modern/Velocity forwarding](velocity-modern.md) does **not** touch the handshake address field, so the `\0FML\0` host collision goes away — the FML marker and the identity data no longer fight over the same field. The remaining interplay is the **Forge login-phase handshake itself**, which runs as login plugin messages on the same login state where Velocity sends `velocity:player_info`. The two coexist (different channels), but a modded backend's FML handshake and the forwarding handshake must both complete during login.
Practical notes:
- **Modern Forge (1.13+)** uses its own login plugin-message handshake; a Velocity backend running modern forwarding handles both because they're distinct channels. <!-- VERIFY: modern Forge + native modern forwarding compatibility depends on the backend mod (e.g. a Velocity-forwarding mod alongside Forge); not exhaustively read from source -->
- **ViaForge / client-side shims**: tools like ViaForge let a Forge client speak to a backend across version gaps; they have to reproduce or tolerate the FML handshake markers so the proxy and backend negotiate the modded handshake correctly. <!-- VERIFY: ViaForge specifics not read from cloned source -->
- For modded networks, **modern forwarding is preferable** precisely because it sidesteps the address-field collision that makes legacy + Forge brittle.
## Summary
- The `\0FML\0` / `\0FML2\0` / `\0FML3\0` marker is Forge saying "I'm modded," appended to the handshake host.
- It **collides with legacy forwarding** (shared field) → proxies must split it off and restore it, and **can't** restore it while legacy IP-forwarding is on (BungeeCord TODO; Velocity moves the flag to a property).
- **Modern forwarding avoids the field entirely**, so it's the cleaner choice for Forge backends; the Forge mod-list handshake then runs as separate login plugin messages.
---
**Sources**
- `BungeeCord/proxy/.../forge/ForgeConstants.java:13-20``FML_TAG`, `FML_HANDSHAKE_TAG = "FML|HS"`, `FML_HANDSHAKE_TOKEN = "\0FML\0"`.
- `BungeeCord/proxy/.../connection/InitialHandler.java:355-360` — split the `\0FML\0` tail off the host, save as `extraDataInHandshake`.
- `BungeeCord/proxy/.../ServerConnector.java:124-128` — restore the FML tail only when IP forwarding is off (`:127` TODO notes the gap when it's on).
- `Velocity/proxy/.../forge/legacy/LegacyForgeConstants.java:29,34``HANDSHAKE_HOSTNAME_TOKEN = "\0FML\0"`, `FORGE_LEGACY_HANDSHAKE_CHANNEL = "FML|HS"`.
- `Velocity/proxy/.../forge/legacy/LegacyForgeConnectionType.java:41-49` — legacy forwarding can't carry the FML host token; flag moved to a profile property instead.
- See [bungeecord-legacy.md](bungeecord-legacy.md) (the colliding `\0` transport) and [velocity-modern.md](velocity-modern.md) (the field-free alternative).
+72
View File
@@ -0,0 +1,72 @@
# Online vs Offline Mode
The `online-mode` server property is the root of every forwarding decision. Understand it first; the four forwarding modes are all answers to the problem offline mode creates.
## What `online-mode` controls
`online-mode=true` (the default in `server.properties`) means: **after** the login handshake, the server verifies the connecting player against Mojang's session servers. It does **not** by itself control whether the connection is encrypted.
The login sequence (see [../05-login-encryption.md](../05-login-encryption.md) for the full packet flow) is, in both modes:
1. Client → `LoginStart` (username, and on 1.19+ a profile public key / on 1.20.2+ the profile UUID).
2. Server → `Encryption Request` (server-id string, server's RSA public key, verify token).
3. Client → `Encryption Response` (the AES shared secret + verify token, both RSA-encrypted with the server's public key).
4. Both sides switch to **AES/CFB8** encryption using the shared secret.
Steps 24 — the encryption negotiation — **happen regardless of online/offline mode** whenever the vanilla server requests them. What online mode adds is one extra check between steps 3 and 4 conceptually:
- **Online mode**: the server computes the *server-id hash* (SHA-1 over the server-id string + shared secret + server public key) and calls Mojang's session endpoint **`hasJoined`**:
`https://sessionserver.mojang.com/session/minecraft/hasJoined?username=<name>&serverId=<hash>`.
Mojang confirms that this account really just authenticated with that server-id (the client side calls the matching `join` endpoint). The response carries the player's **real account UUID** and **properties** (the `textures` property — skin & cape, signed by Mojang). If `hasJoined` returns nothing, the login is rejected.
- **Offline mode**: the server **skips the `hasJoined` call entirely**. No Mojang verification. The server trusts the username from `LoginStart` as-is. (Vanilla offline servers also skip the encryption step; the point is that *no identity proof* is required.)
So the difference that matters for forwarding is: **offline mode does not call `hasJoined`, so it has no proof the username is real, and it gets no real UUID or skin from Mojang.** It must invent both.
## UUID derivation
How the player's UUID is determined differs by mode:
- **Online mode**: the UUID is the player's **real Mojang account UUID**, returned by the `hasJoined` response. It's a stable, account-bound, "version 4"-style identifier assigned by Mojang.
- **Offline mode**: the server **derives** a UUID deterministically from the username. The algorithm is a **name-based (version 3 / MD5) UUID** over the bytes of the ASCII string `"OfflinePlayer:" + username`:
```java
UUID offlineId = UUID.nameUUIDFromBytes(
("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
```
`java.util.UUID.nameUUIDFromBytes` produces an **RFC-4122 version 3** UUID — it MD5-hashes the input bytes and stamps the version/variant bits. The same username always yields the same offline UUID, on every server, forever. (Note: no namespace UUID is prepended — it's a plain MD5 of just those bytes, which is why this is "version-3-like" rather than a strictly RFC-compliant namespaced v3.)
This is confirmed directly in BungeeCord's source — `InitialHandler.finish()`:
> `offlineId = UUID.nameUUIDFromBytes( ( "OfflinePlayer:" + getName() ).getBytes( StandardCharsets.UTF_8 ) );`
>
> — `BungeeCord/proxy/src/main/java/net/md_5/bungee/connection/InitialHandler.java:560`
and the [minecraft.wiki](https://minecraft.wiki/w/Universally_unique_identifier) describes it the same way: *"the unique identifier chosen for offline mode players is UUID version 3, generated from the MD5 hash of `OfflinePlayer:<username>`."*
The practical consequence: a premium player has **different** UUIDs online vs offline. This is exactly why forwarding must carry the *real* (online) UUID to the backend — otherwise the offline backend would assign the player a different identity than the one their account/inventory/permissions are keyed to.
## Security model
> **An offline-mode backend with a reachable port is wide open.**
Because an offline server does no `hasJoined` check, it accepts **whatever username the connecting party sends in `LoginStart`** and derives the UUID from it. There is no proof. Anyone who can reach the port can connect as `Notch`, as an admin, as any player — and the offline server will hand them that player's UUID, inventory, and op level.
In a proxy setup the player's identity is established **at the proxy** (which runs online mode and does the real `hasJoined`). The backends are deliberately offline so the proxy can move players. That is the entire reason forwarding + hardening exist:
1. **Forwarding** gives the backend the player's *real* IP / UUID / properties instead of the bogus offline-derived ones — see [bungeecord-legacy.md](bungeecord-legacy.md) and [velocity-modern.md](velocity-modern.md).
2. **Hardening** stops an attacker from connecting *directly* to the offline backend and forging that same identity:
- **Firewall** the backend so only the proxy's address can connect (the *only* defense for bare legacy / none).
- **BungeeGuard** — a shared secret token the backend checks ([bungeeguard.md](bungeeguard.md)).
- **Modern/Velocity** — an HMAC the backend verifies, so forged identities are cryptographically rejected ([velocity-modern.md](velocity-modern.md)).
Bind your backends to a private interface and trust nothing that arrives without one of these proofs.
---
**Sources**
- `BungeeCord/proxy/src/main/java/net/md_5/bungee/connection/InitialHandler.java:560` — offline UUID = `nameUUIDFromBytes("OfflinePlayer:"+name)`; `:135` `onlineMode`; `:595` `isOnlineMode()` gating the auth call.
- [minecraft.wiki — Universally unique identifier](https://minecraft.wiki/w/Universally_unique_identifier) — version-3 MD5 `OfflinePlayer:<username>` derivation.
- [minecraft.wiki — Java Edition protocol (login + encryption)](https://minecraft.wiki/w/Java_Edition_protocol) — Encryption Request/Response, server-id hash, `hasJoined`.
- See also [../05-login-encryption.md](../05-login-encryption.md) for the encryption packet detail.
+132
View File
@@ -0,0 +1,132 @@
# Velocity Modern Forwarding
The **gold-standard** forwarding mode. The proxy and every backend share a secret string; the proxy signs the forwarded identity with **HMAC-SHA256** keyed by that secret, and the backend refuses to start the player's session unless the HMAC verifies. An attacker who reaches the backend's port still cannot forge an identity, because they don't have the secret — so this mode is safe **even if the backend port is exposed**, unlike [legacy](bungeecord-legacy.md) (firewall-only) forwarding.
It is called "modern" because, unlike legacy, it does **not** abuse the handshake address field. It rides the protocol's own **Login Plugin Message** mechanism (login-state plugin channels), which is a clean, in-band request/response added in the modern protocol.
## Setup
- **Proxy** (Velocity): `player-info-forwarding-mode = "modern"` and a `forwarding.secret` (a random secret string, stored in a `forwarding.secret` file).
- **Backend** (Paper): `paper.yml` (or `config/paper-global.yml`) → `proxies.velocity.enabled: true`, `proxies.velocity.online-mode: true`, `proxies.velocity.secret: <same secret>`. <!-- VERIFY: exact paper.yml key path varies by Paper version; secret value must equal the proxy's --> Fabric/Forge backends use a mod (e.g. FabricProxy-Lite) that implements the same handshake.
The backend still runs `online-mode=false` at the vanilla level — modern forwarding *is* its identity source.
## The handshake direction (important)
Normal Login Plugin Messages go server→client. Here the roles are inverted: the **backend** is the "server" and the **proxy** is the "client" of that backend connection. So:
1. The proxy connects to the backend and sends the normal **Handshake** + **LoginStart** (just the username; no identity stuffed into the address — that is the whole difference from legacy).
2. The **backend** sends a **Login Plugin Request** on channel **`velocity:player_info`**, asking "who is this, and prove it." It may include one byte: the highest forwarding version the backend supports.
3. The **proxy** replies with a **Login Plugin Response** carrying the HMAC-signed identity payload.
4. The backend verifies the HMAC, reads the identity, and proceeds. If no `velocity:player_info` request was answered by login's end, Velocity aborts with *"If you are a server owner, make sure you have ... `forwarding-secret`."* (`MODERN_IP_FORWARDING_FAILURE`).
The channel name and version constants are defined in Velocity source:
> ```java
> public static final String CHANNEL = "velocity:player_info";
> public static final int MODERN_DEFAULT = 1; // base payload
> public static final int MODERN_WITH_KEY = 2; // + 1.19 profile key
> public static final int MODERN_WITH_KEY_V2 = 3; // + signer UUID (1.19.1+)
> public static final int MODERN_LAZY_SESSION = 4; // 1.19.3+, key dropped again
> public static final int MODERN_MAX_VERSION = MODERN_LAZY_SESSION;
> ```
>
> — `Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/PlayerDataForwarding.java:42-48`
(The Velocity-side request handler reads the backend's requested version: `if (packet.content().readableBytes() == 1) requestedForwardingVersion = packet.content().readByte();``backend/LoginSessionHandler.java:89-93`.)
## Exact payload layout
The proxy builds the response body in `PlayerDataForwarding.createForwardingData(...)`. The **signed payload** is, in order:
| # | Field | Type | Notes |
|---|---|---|---|
| 1 | forwarding version | VarInt | the negotiated version (14) |
| 2 | client IP | String | the player's real remote address |
| 3 | player UUID | UUID | the real (online) UUID — 16 bytes, **not** a string |
| 4 | username | String | |
| 5 | game-profile properties | Properties | array of `{name, value, signature?}` — includes the Mojang-signed **`textures`** (skin/cape) |
| 6 | *(if version 23)* player public key | IdentifiedKey | 1.19 message-signing key |
| 7 | *(if version ≥ 3)* signer UUID present? + UUID | Boolean [+ UUID] | the key's signature-holder UUID, when known |
Then the whole thing is **prefixed** by its HMAC. The signed buffer is built first, the MAC is computed over exactly those bytes, and the 32-byte HMAC is concatenated **in front**:
> ```java
> ProtocolUtils.writeVarInt(forwarded, actualVersion);
> ProtocolUtils.writeString(forwarded, address);
> ProtocolUtils.writeUuid(forwarded, profile.getId());
> ProtocolUtils.writeString(forwarded, profile.getName());
> ProtocolUtils.writeProperties(forwarded, profile.getProperties());
> // ... (optional player key / signer UUID for versions 23) ...
>
> final Mac mac = Mac.getInstance(ALGORITHM); // "HmacSHA256"
> mac.init(new SecretKeySpec(secret, ALGORITHM)); // keyed by the shared secret
> mac.update(forwarded.array(), forwarded.arrayOffset(), forwarded.readableBytes());
> final byte[] sig = mac.doFinal();
>
> return Unpooled.wrappedBuffer(Unpooled.wrappedBuffer(sig), forwarded);
> ```
>
> — `Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/PlayerDataForwarding.java:69-102`
> (`ALGORITHM = "HmacSHA256"` at `:40`)
So the **wire body of the Login Plugin Response is**:
```
[ HMAC-SHA256(secret, payload) : 32 bytes ] ++ [ payload ]
^ VarInt version, String IP, UUID,
String name, Properties, (key…)
```
The backend recomputes `HMAC-SHA256(secret, payload)` over the bytes after the first 32, and rejects the login if it doesn't match the prefix. Because the secret never crosses the wire and the IP/UUID/name/props are all inside the MAC'd region, **nothing can be tampered with or forged** without the secret. That is the security win over legacy and BungeeGuard.
## Sequence
```mermaid
sequenceDiagram
autonumber
actor C as "Client (player)"
participant P as "Proxy (Velocity, mode=modern, secret=S)"
participant B as "Backend (Paper, velocity.secret=S, offline-mode)"
C->>P: "Handshake + LoginStart (username)"
Note over P: "online-mode auth vs Mojang (hasJoined)"
P->>B: "Handshake + LoginStart (username only)"
B->>P: "Login Plugin Request 'velocity:player_info' (+ max ver byte)"
Note over P: "payload = version,IP,UUID,name,props[,key]"
Note over P: "sig = HMAC-SHA256(S, payload)"
P->>B: "Login Plugin Response: sig(32B) ++ payload"
Note over B: "recompute HMAC(S, payload); compare to sig"
alt "HMAC matches"
B-->>C: "LoginSuccess (via proxy relay) -> play"
else "HMAC mismatch / no response"
B-->>P: "disconnect (forwarding failure)"
end
```
## Why it's the gold standard
| Property | Modern | Legacy | BungeeGuard |
|---|---|---|---|
| Identity tamper-proof | **Yes** (whole payload HMAC'd) | No | partial (token gates, but IP/UUID still plaintext) |
| Safe with backend port exposed | **Yes** | No (firewall-only) | mostly (attacker lacks token) |
| Secret crosses the wire | **No** (only the HMAC does) | n/a | **Yes** (token sent in cleartext properties) |
| Uses protocol's own channel mechanism | Yes (Login Plugin Message) | No (address-field abuse) | No (address-field abuse) |
The only catch is **backend support**: every backend must implement modern forwarding (Paper natively; Fabric/Forge via a mod). Where a backend can only speak legacy, fall back to [BungeeGuard](bungeeguard.md). For Forge interplay, see [forge-fml.md](forge-fml.md).
## Login Plugin Message reference
The transport is the protocol's login-state plugin messaging (see also [../05-login-encryption.md](../05-login-encryption.md)):
- **Login Plugin Request** (clientbound, login state, packet `0x04`): Message ID (VarInt), Channel (Identifier), Data (byte array, channel-specific, no length prefix). Here the *backend* sends it. <!-- VERIFY: packet ID 0x04 is current-protocol; older versions differ -->
- **Login Plugin Response** (serverbound, login state, packet `0x02`): Message ID (VarInt), then a **prefixed-optional** Data byte array — present only if the request was understood. An unrecognized channel is answered with the empty/"not understood" form. Here the *proxy* sends it, with Data = `sig ++ payload`.
— [minecraft.wiki — Java Edition protocol (Login Plugin Request / Response)](https://minecraft.wiki/w/Java_Edition_protocol)
---
**Sources**
- `Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/PlayerDataForwarding.java:40` (`ALGORITHM="HmacSHA256"`), `:42-48` (channel + forwarding-version constants), `:57-111` (`createForwardingData` — payload order + HMAC prefix), `:99-102` (MAC over the payload, `wrappedBuffer(sig, forwarded)`).
- `Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/LoginSessionHandler.java:83-106` (backend's `velocity:player_info` request handling → proxy reply), `:62` + `:146-147` (`MODERN_IP_FORWARDING_FAILURE` when no info was forwarded).
- [minecraft.wiki — Java Edition protocol](https://minecraft.wiki/w/Java_Edition_protocol) — Login Plugin Request (0x04) / Response (0x02) field layout.