package client import ( "crypto/hmac" "crypto/md5" "crypto/sha256" "errors" "sync" "sync/atomic" "github.com/iceBear67/redapricot/client/wire" ) // Velocity "modern forwarding" support. // // A Paper backend configured with Velocity modern player-info forwarding runs // in offline mode and instead trusts a signed login payload from its proxy: // during the login phase it sends a Login Plugin Request on channel // "velocity:player_info" and expects a Login Plugin Response whose data is an // HMAC-SHA256 signature followed by the player's real address, UUID, username // and profile properties. // // redapricot is a transparent tunnel, so that request would reach the vanilla // player, who cannot answer it and gets kicked. When a mapping sets // "velocitySecret", the stream answers on the player's behalf: it observes the // player's Handshake and Login Start to learn the protocol version, username // and UUID, swallows the backend's velocity:player_info request instead of // forwarding it, and injects the signed response. Everything else — and // everything after the exchange — is forwarded verbatim. On any traffic that // does not look like a vanilla login (status pings, parse errors, oversized // packets) the stream fails open into pure passthrough. // // The forwarded profile carries no properties (skin/cape textures): the tunnel // never talks to Mojang, exactly like an offline-mode proxy. const ( velocityChannel = "velocity:player_info" // Forwarding payload versions (Velocity's VelocityConstants). We never use // versions 2/3 (WITH_KEY): they exist only for 1.19–1.19.2 chat signing, // and version 1 remains acceptable to every backend. velocityVersionDefault = 1 velocityVersionLazySession = 4 // Minecraft protocol versions at which the Login Start layout changes. protocol1_19 = 759 // + optional signature key protocol1_19_1 = 760 // + optional profile UUID (after the key) protocol1_19_3 = 761 // key removed, optional UUID stays protocol1_20_2 = 764 // UUID mandatory // Handshake intents that enter the login phase. intentLogin = 2 intentTransfer = 3 // Login-phase packet ids (stable across protocol versions). loginC2SPluginResponse = 0x02 loginS2CDisconnect = 0x00 loginS2CEncryptionRequest = 0x01 loginS2CSuccess = 0x02 loginS2CSetCompression = 0x03 loginS2CPluginRequest = 0x04 // Sniff-buffer caps. Login-phase packets are small; anything larger means // this is not the exchange we are looking for. maxC2SSniff = 8 << 10 maxS2CSniff = 64 << 10 ) var errVelocitySniff = errors.New("velocity: connection does not follow the vanilla login flow") // velocityForwarder is the per-stream login interceptor. ObserveC2S is called // from the worker read loop, ProcessS2C from the stream's destination-read // goroutine; the mutex orders them, and passthrough short-circuits both once // interception is over. type velocityForwarder struct { passthrough atomic.Bool // fully transparent, buffers empty: skip the mutex mu sync.Mutex secret []byte srcIP string // player -> server observation c2sBuf []byte c2sDone bool handshakeParsed bool protocol int loginStartSeen bool username string uuid [16]byte // server -> player interception; done means the s2c side (and with it the // whole interceptor) is finished. s2cBuf []byte done bool } func newVelocityForwarder(secret, srcIP string) *velocityForwarder { return &velocityForwarder{secret: []byte(secret), srcIP: srcIP} } // Passthrough reports that interception is over and both directions may skip // the forwarder entirely. func (v *velocityForwarder) Passthrough() bool { return v.passthrough.Load() } // abortLocked gives up on interception: the stream becomes pure passthrough. // s2cBuf is deliberately kept — ProcessS2C flushes it to the player. func (v *velocityForwarder) abortLocked() { v.done = true v.c2sDone = true v.c2sBuf = nil if len(v.s2cBuf) == 0 { v.passthrough.Store(true) } } // ObserveC2S watches player->server bytes (already being forwarded verbatim by // the caller) until the Handshake and Login Start have been parsed. func (v *velocityForwarder) ObserveC2S(data []byte) { if v.passthrough.Load() { return } v.mu.Lock() defer v.mu.Unlock() if v.c2sDone { return } v.c2sBuf = append(v.c2sBuf, data...) for !v.c2sDone { _, body, rest, ok, err := nextPacket(v.c2sBuf, maxC2SSniff) if err != nil { v.abortLocked() return } if !ok { if len(v.c2sBuf) > maxC2SSniff { v.abortLocked() } return } v.c2sBuf = rest if err := v.observeC2SPacket(body); err != nil { v.abortLocked() return } } v.c2sBuf = nil } // observeC2SPacket handles one player packet: first the Handshake, then Login // Start. Any deviation from the vanilla login flow is an error (→ fail open). func (v *velocityForwarder) observeC2SPacket(body []byte) error { r := wire.NewReader(body) id, err := r.VarInt() if err != nil || id != 0x00 { // Handshake and Login Start are both 0x00 return errVelocitySniff } if !v.handshakeParsed { proto, perr := r.VarInt() _, aerr := r.String() // address _, poerr := r.U16() // port intent, ierr := r.VarInt() if perr != nil || aerr != nil || poerr != nil || ierr != nil { return errVelocitySniff } if intent != intentLogin && intent != intentTransfer { return errVelocitySniff // status ping etc.: nothing to intercept } v.protocol = proto v.handshakeParsed = true return nil } return v.parseLoginStart(r) } func (v *velocityForwarder) parseLoginStart(r *wire.Reader) error { name, err := r.String() if err != nil || len(name) == 0 || len(name) > 16 { return errVelocitySniff } if v.protocol >= protocol1_19 && v.protocol < protocol1_19_3 { // Optional chat-signing key: expiry + public key + signature. hasKey, err := r.U8() if err != nil { return errVelocitySniff } if hasKey != 0 { if _, err := r.I64(); err != nil { return errVelocitySniff } for i := 0; i < 2; i++ { n, err := r.VarInt() if err != nil { return errVelocitySniff } if _, err := r.Bytes(n); err != nil { return errVelocitySniff } } } } haveUUID := false switch { case v.protocol >= protocol1_20_2: haveUUID = true case v.protocol >= protocol1_19_1: flag, err := r.U8() if err != nil { return errVelocitySniff } haveUUID = flag != 0 } if haveUUID { b, err := r.Bytes(16) if err != nil { return errVelocitySniff } copy(v.uuid[:], b) } else { v.uuid = offlineUUID(name) } v.username = name v.loginStartSeen = true v.c2sDone = true return nil } // ProcessS2C consumes one chunk of server->player bytes. It returns the bytes // to forward to the player and, once the velocity query has been answered, the // Login Plugin Response to inject towards the server. Complete packets are // forwarded as they parse; a trailing partial packet stays buffered until the // next chunk. func (v *velocityForwarder) ProcessS2C(data []byte) (forward, inject []byte) { v.mu.Lock() defer v.mu.Unlock() if v.done { // Interception ended from the c2s side while bytes sat buffered here. if len(v.s2cBuf) > 0 { forward = append(v.s2cBuf, data...) v.s2cBuf = nil v.passthrough.Store(true) return forward, nil } v.passthrough.Store(true) return data, nil } v.s2cBuf = append(v.s2cBuf, data...) loop: for { raw, body, rest, ok, err := nextPacket(v.s2cBuf, maxS2CSniff) if err != nil || (!ok && len(v.s2cBuf) > maxS2CSniff) { v.abortLocked() // unconsumed bytes are flushed below break } if !ok { break // partial packet: wait for the next chunk } r := wire.NewReader(body) id, err := r.VarInt() if err != nil { v.abortLocked() break } switch id { case loginS2CPluginRequest: msgID, merr := r.VarInt() channel, cerr := r.String() if merr != nil || cerr != nil { v.abortLocked() break loop } if channel == velocityChannel { if !v.loginStartSeen { // Cannot answer without a parsed Login Start; let the // request through — the backend will kick the player with // its own clear message. v.abortLocked() break loop } inject = v.buildResponseLocked(msgID, r.Remaining()) v.s2cBuf = rest // swallow the request: the player never sees it v.done = true break loop } // Another plugin channel (e.g. a mod handshake): the player // answers it itself; forward and keep watching. case loginS2CDisconnect, loginS2CEncryptionRequest, loginS2CSuccess, loginS2CSetCompression: // Login phase is over (or turning encrypted/compressed) and no // velocity query showed up: stop watching. v.done = true default: // Cookie Request (0x05, 1.20.5+) or future packets: forward. } v.s2cBuf = rest forward = append(forward, raw...) if v.done { break } } if v.done { forward = append(forward, v.s2cBuf...) v.s2cBuf = nil v.c2sBuf = nil v.c2sDone = true v.passthrough.Store(true) } return forward, inject } // buildResponseLocked crafts the serverbound Login Plugin Response carrying the // signed forwarding payload (mirrors Velocity's createForwardingData). func (v *velocityForwarder) buildResponseLocked(msgID int, reqData []byte) []byte { // The request data is the backend's maximum supported forwarding version // (absent on very old backends → 1). requested := velocityVersionDefault if len(reqData) > 0 { if n, err := wire.NewReader(reqData).VarInt(); err == nil { requested = n } } version := velocityVersionDefault if requested >= velocityVersionLazySession && v.protocol >= protocol1_19_3 { version = velocityVersionLazySession } payload := wire.NewWriter(). VarInt(version). String(v.srcIP). Bytes(v.uuid[:]). // UUID = 16 raw bytes (two big-endian longs) String(v.username). VarInt(0). // profile properties Out() mac := hmac.New(sha256.New, v.secret) mac.Write(payload) body := wire.NewWriter(). VarInt(loginC2SPluginResponse). VarInt(msgID). U8(1). // successful Bytes(mac.Sum(nil)). Bytes(payload). Out() return append(wire.AppendVarInt(nil, len(body)), body...) } // nextPacket splits one length-prefixed Minecraft packet off buf. raw includes // the length header, body is the packet payload, rest what follows. ok is // false while the packet is still incomplete; err reports a malformed or // oversized length header. func nextPacket(buf []byte, max int) (raw, body, rest []byte, ok bool, err error) { r := wire.NewReader(buf) n, verr := r.VarInt() if verr != nil { if len(buf) >= wire.VarIntMaxBytes { return nil, nil, buf, false, verr } return nil, nil, buf, false, nil // header not complete yet } if n <= 0 || n > max { return nil, nil, buf, false, errVelocitySniff } hdr := len(buf) - len(r.Remaining()) if len(buf) < hdr+n { return nil, nil, buf, false, nil } return buf[:hdr+n], buf[hdr : hdr+n], buf[hdr+n:], true, nil } // offlineUUID derives the offline-mode UUID for a username, identical to // Java's UUID.nameUUIDFromBytes("OfflinePlayer:" + name): a v3 (MD5) UUID. func offlineUUID(name string) [16]byte { sum := md5.Sum([]byte("OfflinePlayer:" + name)) sum[6] = sum[6]&0x0f | 0x30 // version 3 sum[8] = sum[8]&0x3f | 0x80 // IETF variant return sum }