Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 50 additions & 5 deletions transport/internet/finalmask/xmc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package xmc
import (
"bufio"
"bytes"
"crypto/md5"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"fmt"
"io"
Expand All @@ -28,6 +28,7 @@ type clientConn struct {
password string
rsaPublicKey []byte
hostname string
packet *packetStream
}

type clientState int
Expand All @@ -41,7 +42,9 @@ func newClientConn(c net.Conn, usernames []string, password string, rsaPublicKey
if len(rsaPublicKey) == 0 {
return nil, fmt.Errorf("empty rsa public key")
}

if len(usernames) == 0 {
return nil, fmt.Errorf("empty usernames")
}
return &clientConn{
reader: bufio.NewReader(c),
writer: c,
Expand Down Expand Up @@ -100,7 +103,10 @@ func (c *clientConn) handshake() error {
offlineUUID UUID
)

randomUsername, _ := rand.Int(rand.Reader, big.NewInt(int64(len(c.usernames))))
randomUsername, err := rand.Int(rand.Reader, big.NewInt(int64(len(c.usernames))))
if err != nil {
return fmt.Errorf("select username: %w", err)
}
username = c.usernames[randomUsername.Int64()]
generateOfflineUUID(&offlineUUID, string(username))

Expand Down Expand Up @@ -145,7 +151,9 @@ func (c *clientConn) handshake() error {
}

sharedSecret := make([]byte, 16)
rand.Read(sharedSecret)
if _, err = rand.Read(sharedSecret); err != nil {
return fmt.Errorf("generate shared secret: %w", err)
}

encryptedSharedSecret, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPublicKey, sharedSecret)
if err != nil {
Expand Down Expand Up @@ -181,6 +189,40 @@ func (c *clientConn) handshake() error {
return fmt.Errorf("new crypto writer: %w", err)
}

pkt, err = readPacket(c.reader)
if err != nil {
return fmt.Errorf("read login success: %w", err)
}
if pkt.packetID == 0x00 {
var reason String
if readErr := pkt.readFields(&reason); readErr != nil {
return fmt.Errorf("authentication rejected")
}
return fmt.Errorf("authentication rejected: %s", reason)
}
if pkt.packetID != 0x02 {
return fmt.Errorf("bad login success packet id: %d", pkt.packetID)
}

var (
loginUUID UUID
loginName String
properties Varint
)
if err = pkt.readFields(&loginUUID, &loginName, &properties); err != nil {
return fmt.Errorf("read login success fields: %w", err)
}
if properties != 0 {
return fmt.Errorf("unsupported login property count: %d", properties)
}
if err = writePacket(c.writer, 0x03); err != nil {
return fmt.Errorf("write login acknowledged: %w", err)
}

c.packet = newPacketStream(c.reader, c.writer, true)
c.reader = c.packet
c.writer = c.packet

c.state = clientStateProxy

return nil
Expand All @@ -205,6 +247,9 @@ func (c *clientConn) Write(b []byte) (int, error) {
}

func (c *clientConn) Close() error {
if c.packet != nil {
c.packet.Stop()
}
return c.c.Close()
}

Expand All @@ -229,7 +274,7 @@ func (c *clientConn) SetWriteDeadline(t time.Time) error {
}

func generateOfflineUUID(uuid *UUID, username string) {
h := sha256.Sum256([]byte("OfflinePlayer:" + username))
h := md5.Sum([]byte("OfflinePlayer:" + username))
copy(uuid[:], h[:16])
uuid[6] = (uuid[6] & 0x0f) | 0x30 // UUID version 3
uuid[8] = (uuid[8] & 0x3f) | 0x80 // UUID variant
Expand Down
6 changes: 2 additions & 4 deletions transport/internet/finalmask/xmc/handshake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,10 @@ func TestHandshakePasswordMismatch(t *testing.T) {
}

err = client.handshake()
if err != nil {
t.Fatalf("client handshake err: %v", err)
if err == nil {
t.Fatal("expected client handshake to fail due to password mismatch")
}

_, _ = client.Write([]byte{0x1, 0x2, 0x3, 0x4})

wg.Wait()

// Check if we lost connection or received error
Expand Down
184 changes: 184 additions & 0 deletions transport/internet/finalmask/xmc/packet_stream.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package xmc

import (
"bytes"
"fmt"
"io"
"sync"
"sync/atomic"
"time"
)

const (
configurationClientboundCustomPayload = 0x01
configurationServerboundCustomPayload = 0x02
configurationKeepAlive = 0x04

packetChannel = "xmc:data"
maxPacketData = 24 * 1024
keepAlivePeriod = 15 * time.Second
)

// packetStream carries the raw proxy byte stream in Minecraft configuration
// custom payload packets. The configuration state provides bidirectional
// payload packets and keep-alives without requiring version-specific world data.
type packetStream struct {
reader io.Reader
writer io.Writer
isClient bool

readMu sync.Mutex
writeMu sync.Mutex
pending []byte

keepAliveID atomic.Int64
done chan struct{}
stopOnce sync.Once
}

func newPacketStream(reader io.Reader, writer io.Writer, isClient bool) *packetStream {
s := &packetStream{
reader: reader,
writer: writer,
isClient: isClient,
done: make(chan struct{}),
}
if !isClient {
go s.keepAliveLoop()
}
return s
}

func (s *packetStream) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}

s.readMu.Lock()
defer s.readMu.Unlock()

if len(s.pending) > 0 {
n := copy(p, s.pending)
s.pending = s.pending[n:]
return n, nil
}

for {
packet, err := readPacket(s.reader)
if err != nil {
return 0, fmt.Errorf("read minecraft packet stream: %w", err)
}

if packet.packetID == s.remoteCustomPayloadID() {
payload, ok, err := parseCustomPayload(packet)
if err != nil {
return 0, err
}
if !ok || len(payload) == 0 {
continue
}

n := copy(p, payload)
if n < len(payload) {
s.pending = append(s.pending[:0], payload[n:]...)
}
return n, nil
}

if packet.packetID == configurationKeepAlive {
var id Long
if err := packet.readFields(&id); err != nil {
return 0, fmt.Errorf("read minecraft keep-alive: %w", err)
}
if s.isClient {
if err := s.writeKeepAlive(id); err != nil {
return 0, err
}
}
}
}
}

func (s *packetStream) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}

s.writeMu.Lock()
defer s.writeMu.Unlock()

written := 0
for written < len(p) {
end := written + maxPacketData
if end > len(p) {
end = len(p)
}
channel := String(packetChannel)
payload := RestBytes(p[written:end])
if err := writePacket(s.writer, s.localCustomPayloadID(), &channel, &payload); err != nil {
return written, fmt.Errorf("write minecraft custom payload: %w", err)
}
written = end
}

return written, nil
}

func (s *packetStream) Stop() {
s.stopOnce.Do(func() { close(s.done) })
}

func (s *packetStream) localCustomPayloadID() int {
if s.isClient {
return configurationServerboundCustomPayload
}
return configurationClientboundCustomPayload
}

func (s *packetStream) remoteCustomPayloadID() int {
if s.isClient {
return configurationClientboundCustomPayload
}
return configurationServerboundCustomPayload
}

func parseCustomPayload(packet *mcPacket) ([]byte, bool, error) {
r := bytes.NewReader(packet.data)
var channel String
if err := channel.readFrom(r); err != nil {
return nil, false, fmt.Errorf("read minecraft custom payload channel: %w", err)
}
if string(channel) != packetChannel {
return nil, false, nil
}
payload := make([]byte, r.Len())
if _, err := io.ReadFull(r, payload); err != nil {
return nil, false, fmt.Errorf("read minecraft custom payload data: %w", err)
}
return payload, true, nil
}

func (s *packetStream) writeKeepAlive(id Long) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
if err := writePacket(s.writer, configurationKeepAlive, &id); err != nil {
return fmt.Errorf("write minecraft keep-alive: %w", err)
}
return nil
}

func (s *packetStream) keepAliveLoop() {
ticker := time.NewTicker(keepAlivePeriod)
defer ticker.Stop()
for {
select {
case <-ticker.C:
id := Long(s.keepAliveID.Add(1))
if err := s.writeKeepAlive(id); err != nil {
return
}
case <-s.done:
return
}
}
}
81 changes: 81 additions & 0 deletions transport/internet/finalmask/xmc/packet_stream_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package xmc

import (
"bytes"
"io"
"net"
"testing"
)

func TestPacketStreamRoundTrip(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()

const password = "packet-stream-shared-key"
privateKey, publicKey := deriveTestRSAKey(t, password)
clientPayload := bytes.Repeat([]byte("client-payload-"), 5000)
serverPayload := bytes.Repeat([]byte("server-payload-"), 5000)
serverDone := make(chan error, 1)

go func() {
rawConn, acceptErr := ln.Accept()
if acceptErr != nil {
serverDone <- acceptErr
return
}
defer rawConn.Close()

server, wrapErr := wrapConnServer(rawConn, password, privateKey, publicKey)
if wrapErr != nil {
serverDone <- wrapErr
return
}
got := make([]byte, len(clientPayload))
if _, readErr := io.ReadFull(server, got); readErr != nil {
serverDone <- readErr
return
}
if !bytes.Equal(got, clientPayload) {
serverDone <- io.ErrUnexpectedEOF
return
}
_, writeErr := server.Write(serverPayload)
serverDone <- writeErr
}()

rawClient, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer rawClient.Close()

client, err := newClientConn(rawClient, []string{"packet_user"}, password, publicKey, "localhost")
if err != nil {
t.Fatal(err)
}
if _, err = client.Write(clientPayload); err != nil {
t.Fatalf("write payload: %v", err)
}
got := make([]byte, len(serverPayload))
if _, err = io.ReadFull(client, got); err != nil {
t.Fatalf("read payload: %v", err)
}
if !bytes.Equal(got, serverPayload) {
t.Fatal("server payload mismatch")
}
if err = <-serverDone; err != nil {
t.Fatalf("server: %v", err)
}
}

func TestOfflineUUIDVersion3(t *testing.T) {
var got UUID
generateOfflineUUID(&got, "Steve")
want := UUID{0x56, 0x27, 0xdd, 0x98, 0xe6, 0xbe, 0x3c, 0x21, 0xb8, 0xa8, 0xe9, 0x23, 0x44, 0x18, 0x36, 0x41}
if got != want {
t.Fatalf("offline UUID = %x, want %x", got, want)
}
}
Loading