--- /dev/null +++ b/protocol/minecraft/protocol.go @@ -0,0 +1,656 @@ +// OMV +package minecraft + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/smux" +) + +// Minecraft protocol constants +const ( + protocolVersion = 765 // 1.20.4 + versionName = "1.20.4" + maxPacketSize = 2097151 // 2^21 - 1 + + stateHandshake = 0 + stateStatus = 1 + stateLogin = 2 + + // Handshake state packets + packetHandshake = 0x00 + + // Status state packets + packetStatusRequest = 0x00 + packetStatusResponse = 0x00 + packetPingRequest = 0x01 + packetPingResponse = 0x01 + + // Login state packets + packetLoginStart = 0x00 + packetEncryptionRequest = 0x01 + packetEncryptionResponse = 0x01 + packetLoginSuccess = 0x02 + packetLoginDisconnect = 0x00 + + rsaKeyBits = 1024 + verifyTokenLen = 4 + sharedSecretLen = 16 +) + +// VarInt encoding/decoding + +func readVarInt(r io.Reader) (int32, error) { + var result int32 + var shift uint + buf := make([]byte, 1) + for { + _, err := io.ReadFull(r, buf) + if err != nil { + return 0, err + } + b := buf[0] + result |= int32(b&0x7F) << shift + if b&0x80 == 0 { + break + } + shift += 7 + if shift >= 35 { + return 0, fmt.Errorf("VarInt too big") + } + } + return result, nil +} + +func writeVarInt(w io.Writer, value int32) error { + buf := encodeVarInt(value) + _, err := w.Write(buf) + return err +} + +func encodeVarInt(value int32) []byte { + var buf [5]byte + n := 0 + uv := uint32(value) + for { + b := byte(uv & 0x7F) + uv >>= 7 + if uv != 0 { + b |= 0x80 + } + buf[n] = b + n++ + if uv == 0 { + break + } + } + return buf[:n] +} + +func varIntLen(value int32) int { + return len(encodeVarInt(value)) +} + +// Packet reading/writing + +func readPacket(r io.Reader) (packetID int32, data []byte, err error) { + length, err := readVarInt(r) + if err != nil { + return 0, nil, err + } + if length < 0 || length > maxPacketSize { + return 0, nil, fmt.Errorf("invalid packet length: %d", length) + } + payload := make([]byte, length) + _, err = io.ReadFull(r, payload) + if err != nil { + return 0, nil, err + } + pr := bytes.NewReader(payload) + packetID, err = readVarInt(pr) + if err != nil { + return 0, nil, err + } + data = payload[varIntLen(packetID):] + return packetID, data, nil +} + +func writePacket(w io.Writer, packetID int32, data []byte) error { + idBytes := encodeVarInt(packetID) + totalLen := int32(len(idBytes) + len(data)) + if err := writeVarInt(w, totalLen); err != nil { + return err + } + if _, err := w.Write(idBytes); err != nil { + return err + } + if _, err := w.Write(data); err != nil { + return err + } + return nil +} + +// String encoding (VarInt length + UTF-8 bytes) + +func readString(r io.Reader) (string, error) { + length, err := readVarInt(r) + if err != nil { + return "", err + } + if length < 0 || length > 32767 { + return "", fmt.Errorf("string too long: %d", length) + } + buf := make([]byte, length) + _, err = io.ReadFull(r, buf) + if err != nil { + return "", err + } + return string(buf), nil +} + +func encodeString(s string) []byte { + var buf bytes.Buffer + buf.Write(encodeVarInt(int32(len(s)))) + buf.WriteString(s) + return buf.Bytes() +} + +// Byte array encoding (VarInt length + bytes) + +func readByteArray(r io.Reader) ([]byte, error) { + length, err := readVarInt(r) + if err != nil { + return nil, err + } + if length < 0 || length > 1048576 { + return nil, fmt.Errorf("byte array too long: %d", length) + } + buf := make([]byte, length) + _, err = io.ReadFull(r, buf) + if err != nil { + return nil, err + } + return buf, nil +} + +func encodeByteArray(data []byte) []byte { + var buf bytes.Buffer + buf.Write(encodeVarInt(int32(len(data)))) + buf.Write(data) + return buf.Bytes() +} + +// Handshake packet + +type handshakePacket struct { + ProtocolVersion int32 + ServerAddress string + ServerPort uint16 + NextState int32 +} + +func readHandshake(data []byte) (*handshakePacket, error) { + r := bytes.NewReader(data) + pv, err := readVarInt(r) + if err != nil { + return nil, err + } + addr, err := readString(r) + if err != nil { + return nil, err + } + var port uint16 + if err := binary.Read(r, binary.BigEndian, &port); err != nil { + return nil, err + } + ns, err := readVarInt(r) + if err != nil { + return nil, err + } + return &handshakePacket{ + ProtocolVersion: pv, + ServerAddress: addr, + ServerPort: port, + NextState: ns, + }, nil +} + +func encodeHandshake(h *handshakePacket) []byte { + var buf bytes.Buffer + buf.Write(encodeVarInt(h.ProtocolVersion)) + buf.Write(encodeString(h.ServerAddress)) + binary.Write(&buf, binary.BigEndian, h.ServerPort) + buf.Write(encodeVarInt(h.NextState)) + return buf.Bytes() +} + +// Login Start packet + +type loginStartPacket struct { + Name string + UUID [16]byte +} + +func readLoginStart(data []byte) (*loginStartPacket, error) { + r := bytes.NewReader(data) + name, err := readString(r) + if err != nil { + return nil, err + } + pkt := &loginStartPacket{Name: name} + // Read UUID (16 bytes) if available + if r.Len() >= 16 { + io.ReadFull(r, pkt.UUID[:]) + } + return pkt, nil +} + +func encodeLoginStart(name string) []byte { + var buf bytes.Buffer + buf.Write(encodeString(name)) + // Write zero UUID + buf.Write(make([]byte, 16)) + return buf.Bytes() +} + +// Encryption Request packet + +type encryptionRequestPacket struct { + ServerID string + PublicKey []byte + VerifyToken []byte +} + +func encodeEncryptionRequest(serverID string, pubKey []byte, verifyToken []byte) []byte { + var buf bytes.Buffer + buf.Write(encodeString(serverID)) + buf.Write(encodeByteArray(pubKey)) + buf.Write(encodeByteArray(verifyToken)) + // Note: ShouldAuthenticate field was added in 1.20.5 (protocol 766). + // We target 1.20.4 (protocol 765), so this field is absent. + return buf.Bytes() +} + +func readEncryptionRequest(data []byte) (*encryptionRequestPacket, error) { + r := bytes.NewReader(data) + serverID, err := readString(r) + if err != nil { + return nil, err + } + pubKey, err := readByteArray(r) + if err != nil { + return nil, err + } + verifyToken, err := readByteArray(r) + if err != nil { + return nil, err + } + return &encryptionRequestPacket{ + ServerID: serverID, + PublicKey: pubKey, + VerifyToken: verifyToken, + }, nil +} + +// Encryption Response packet + +type encryptionResponsePacket struct { + SharedSecret []byte + VerifyToken []byte +} + +func encodeEncryptionResponse(sharedSecret []byte, verifyToken []byte) []byte { + var buf bytes.Buffer + buf.Write(encodeByteArray(sharedSecret)) + buf.Write(encodeByteArray(verifyToken)) + return buf.Bytes() +} + +func readEncryptionResponse(data []byte) (*encryptionResponsePacket, error) { + r := bytes.NewReader(data) + secret, err := readByteArray(r) + if err != nil { + return nil, err + } + token, err := readByteArray(r) + if err != nil { + return nil, err + } + return &encryptionResponsePacket{ + SharedSecret: secret, + VerifyToken: token, + }, nil +} + +// Login Success packet + +func encodeLoginSuccess(name string) []byte { + // Generate a deterministic UUID from username + hash := sha1.Sum([]byte("OfflinePlayer:" + name)) + hash[6] = hash[6]&0x0f | 0x30 // version 3 + hash[8] = hash[8]&0x3f | 0x80 // variant 2 + + var buf bytes.Buffer + buf.Write(hash[:16]) // UUID + buf.Write(encodeString(name)) + buf.Write(encodeVarInt(0)) // Number Of Properties = 0 + buf.WriteByte(0x01) // Strict Error Handling = true + return buf.Bytes() +} + +// Disconnect (Login) packet — plain text reason +func encodeLoginDisconnect(reason string) []byte { + msg, _ := json.Marshal(map[string]string{"text": reason}) + return encodeString(string(msg)) +} + +// Disconnect (Login) packet — translation key (e.g. multiplayer.disconnect.server_full) +func encodeLoginDisconnectTranslate(key string) []byte { + msg, _ := json.Marshal(map[string]string{"translate": key}) + return encodeString(string(msg)) +} + +// Status Response packet — mirrors real Vanilla server JSON format + +type statusResponse struct { + Version *statusVersion `json:"version,omitempty"` + Players *statusPlayers `json:"players,omitempty"` + Description json.RawMessage `json:"description,omitempty"` + Favicon string `json:"favicon,omitempty"` + EnforcesSecureChat bool `json:"enforcesSecureChat,omitempty"` +} + +type statusVersion struct { + Name string `json:"name"` + Protocol int `json:"protocol"` +} + +type statusPlayers struct { + Max int `json:"max"` + Online int `json:"online"` + Sample []statusPlayerSample `json:"sample,omitempty"` +} + +type statusPlayerSample struct { + Name string `json:"name"` + ID string `json:"id"` +} + +// encodeDescription converts a json.RawMessage description to the wire format. +// Accepts a JSON Chat Component (object/array), a JSON string, or null/empty. +// A JSON string like "hello" is unwrapped and re-wrapped as {"text": "hello"}. +func encodeDescription(desc json.RawMessage) json.RawMessage { + if len(desc) == 0 || string(desc) == "null" { + data, _ := json.Marshal(map[string]string{"text": "A Minecraft Server"}) + return data + } + // Already an object or array — use as-is + if desc[0] == '{' || desc[0] == '[' { + return desc + } + // JSON string value (e.g. "hello") — unwrap and wrap as {"text": "..."} + if desc[0] == '"' { + var s string + if json.Unmarshal(desc, &s) == nil { + data, _ := json.Marshal(map[string]string{"text": s}) + return data + } + } + // Fallback + data, _ := json.Marshal(map[string]string{"text": "A Minecraft Server"}) + return data +} + +func encodeStatusResponse(status *option.MinecraftStatus) []byte { + resp := statusResponse{} + + // Version + if status.Version != nil { + resp.Version = &statusVersion{ + Name: status.Version.Name, + Protocol: status.Version.Protocol, + } + } else { + resp.Version = &statusVersion{Name: versionName, Protocol: protocolVersion} + } + + // Players + if status.Players != nil { + players := &statusPlayers{ + Max: status.Players.Max, + Online: status.Players.Online, + } + for _, s := range status.Players.Sample { + id := s.ID + if id == "" { + hash := sha1.Sum([]byte("OfflinePlayer:" + s.Name)) + hash[6] = hash[6]&0x0f | 0x30 + hash[8] = hash[8]&0x3f | 0x80 + id = fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", hash[0:4], hash[4:6], hash[6:8], hash[8:10], hash[10:16]) + } + players.Sample = append(players.Sample, statusPlayerSample{Name: s.Name, ID: id}) + } + resp.Players = players + } else { + resp.Players = &statusPlayers{Max: 20, Online: 0} + } + + // Description (plain text or JSON Chat Component) + resp.Description = encodeDescription(status.Description) + + // Favicon + resp.Favicon = status.Favicon + + // EnforcesSecureChat + resp.EnforcesSecureChat = status.EnforcesSecureChat + + data, _ := json.Marshal(resp) + return encodeString(string(data)) +} + +// Legacy Server List Ping (pre-1.7) +// Detects 0xFE as first byte; responds with 0xFF kick containing server info + +const legacyPingByte = 0xFE + +// descriptionToPlainText extracts plain text from a json.RawMessage description. +// Used for legacy ping which only supports plain text. +func descriptionToPlainText(desc json.RawMessage) string { + if len(desc) == 0 || string(desc) == "null" { + return "A Minecraft Server" + } + // JSON string → unwrap + if desc[0] == '"' { + var s string + if json.Unmarshal(desc, &s) == nil { + return s + } + } + // JSON object → extract "text" field + if desc[0] == '{' { + var obj struct { + Text string `json:"text"` + } + if json.Unmarshal(desc, &obj) == nil && obj.Text != "" { + return obj.Text + } + } + return "A Minecraft Server" +} + +func encodeLegacyPingResponse(status *option.MinecraftStatus) []byte { + ver := versionName + proto := protocolVersion + if status.Version != nil { + if status.Version.Name != "" { + ver = status.Version.Name + } + if status.Version.Protocol != 0 { + proto = status.Version.Protocol + } + } + motd := descriptionToPlainText(status.Description) + var online, max int + if status.Players != nil { + online = status.Players.Online + max = status.Players.Max + } else { + max = 20 + } + + // Response format: 0xFF + string length (uint16 BE) + UTF-16BE string + // String: "§1\0\0\0\0\0" + payload := fmt.Sprintf("\u00a71\x00%d\x00%s\x00%s\x00%d\x00%d", + proto, ver, motd, online, max) + runes := []rune(payload) + + // Build response: 0xFF + length (uint16 BE) + UTF-16BE chars + var buf bytes.Buffer + buf.WriteByte(0xFF) + binary.Write(&buf, binary.BigEndian, uint16(len(runes))) + for _, r := range runes { + binary.Write(&buf, binary.BigEndian, uint16(r)) + } + return buf.Bytes() +} + +// Shared secret derivation from password + verify token + +func deriveSharedSecret(password string, verifyToken []byte) []byte { + h := sha256.New() + h.Write([]byte(password)) + h.Write(verifyToken) + sum := h.Sum(nil) + return sum[:sharedSecretLen] +} + +// AES/CFB8 encrypted connection wrapper +// Minecraft uses CFB8 mode (1-byte segments), not standard CFB128 + +type cfb8Cipher struct { + block cipher.Block + iv []byte +} + +func newCFB8Encrypt(block cipher.Block, iv []byte) *cfb8Cipher { + ivCopy := make([]byte, len(iv)) + copy(ivCopy, iv) + return &cfb8Cipher{block: block, iv: ivCopy} +} + +func newCFB8Decrypt(block cipher.Block, iv []byte) *cfb8Cipher { + ivCopy := make([]byte, len(iv)) + copy(ivCopy, iv) + return &cfb8Cipher{block: block, iv: ivCopy} +} + +func (c *cfb8Cipher) encrypt(dst, src []byte) { + bs := c.block.BlockSize() + tmp := make([]byte, bs) + for i := range src { + c.block.Encrypt(tmp, c.iv) + dst[i] = src[i] ^ tmp[0] + copy(c.iv, c.iv[1:]) + c.iv[bs-1] = dst[i] + } +} + +func (c *cfb8Cipher) decrypt(dst, src []byte) { + bs := c.block.BlockSize() + tmp := make([]byte, bs) + for i := range src { + c.block.Encrypt(tmp, c.iv) + copy(c.iv, c.iv[1:]) + c.iv[bs-1] = src[i] + dst[i] = src[i] ^ tmp[0] + } +} + +// encryptedConn wraps a net.Conn with Minecraft AES/CFB8 encryption +type encryptedConn struct { + net.Conn + enc *cfb8Cipher + dec *cfb8Cipher +} + +func newEncryptedConn(conn net.Conn, sharedSecret []byte) (*encryptedConn, error) { + block, err := aes.NewCipher(sharedSecret) + if err != nil { + return nil, err + } + // Minecraft uses the shared secret as both key and IV + return &encryptedConn{ + Conn: conn, + enc: newCFB8Encrypt(block, sharedSecret), + dec: newCFB8Decrypt(block, sharedSecret), + }, nil +} + +func (c *encryptedConn) Read(b []byte) (int, error) { + n, err := c.Conn.Read(b) + if n > 0 { + c.dec.decrypt(b[:n], b[:n]) + } + return n, err +} + +func (c *encryptedConn) Write(b []byte) (int, error) { + encrypted := make([]byte, len(b)) + c.enc.encrypt(encrypted, b) + return c.Conn.Write(encrypted) +} + +// RSA helpers + +func generateRSAKeyPair() (*rsa.PrivateKey, error) { + return rsa.GenerateKey(rand.Reader, rsaKeyBits) +} + +func marshalPublicKey(pub *rsa.PublicKey) ([]byte, error) { + return x509.MarshalPKIXPublicKey(pub) +} + +func parsePublicKey(data []byte) (*rsa.PublicKey, error) { + pub, err := x509.ParsePKIXPublicKey(data) + if err != nil { + return nil, err + } + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("not an RSA public key") + } + return rsaPub, nil +} + +func generateVerifyToken() ([]byte, error) { + token := make([]byte, verifyTokenLen) + _, err := rand.Read(token) + return token, err +} + +// smux config + +func smuxConfig() *smux.Config { + config := smux.DefaultConfig() + config.KeepAliveDisabled = true + return config +} + +// Stream protocol constants (same as MySQL protocol) +const ( + commandTCP byte = 0x01 + commandUDP byte = 0x03 +)