Files
claude-timemachine a3d5f64ef5 verify pass: resolve VERIFY flags (corrections + citations + honest UNCONFIRMED)
Corrected real errors: several 1.7.x release dates, resource_pack_send version,
config packet ordering, structured-component count (56), PLAYER_LOADED (1.21.4),
entity_sound_effect field order. Confirmed+cited the rest; remaining ~19 items
re-marked UNCONFIRMED (third-party/ViaLegacy/26.2 internals unreachable from refs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:03:44 +02:00

133 lines
9.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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): `config/paper-global.yml` (Paper 1.19+) or `paper.yml` (older Paper) → `proxies.velocity.enabled: true`, `proxies.velocity.online-mode: true`, `proxies.velocity.secret: <same secret>`. Source: [PaperMC global configuration reference](https://docs.papermc.io/paper/reference/global-configuration) — keys `proxies.velocity.{enabled,online-mode,secret}` confirmed. 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. Packet ID `0x04` confirmed stable from 1.19 through 1.21.8 via minecraft-data `data/pc/<ver>/protocol.json` login toClient mappings.
- **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.