XMC finalmask: Add default directional padding/keep-alive packets, matching Minecraft 26.1.2 login/join/etc traffic shapes (#6487)

https://github.com/XTLS/Xray-core/pull/6487#issuecomment-5092235757

---------

Co-authored-by: 风扇滑翔翼 <Fangliding.fshxy@outlook.com>
This commit is contained in:
Sparrow
2026-07-28 06:45:36 +00:00
committed by GitHub
co-authored by 风扇滑翔翼
parent 4aba687dd3
commit 6ab123bf8f
21 changed files with 2853 additions and 178 deletions
+92 -41
View File
@@ -5,7 +5,6 @@ import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"fmt"
"io"
@@ -23,11 +22,16 @@ type clientConn struct {
state clientState
handshakeLock sync.Mutex
usernames []string
password string
rsaPublicKey []byte
hostname string
handshakeLock sync.Mutex
lifecycleMu sync.Mutex
closed bool
profiles []loginProfile
password string
rsaPublicKey []byte
hostname string
paddingSchedule []paddingTurn
packet *packetStream
deadlines *connectionDeadlines
}
type clientState int
@@ -37,21 +41,29 @@ var (
clientStateProxy clientState = 2
)
func newClientConn(c net.Conn, usernames []string, password string, rsaPublicKey []byte, hostname string) (*clientConn, error) {
func newClientConn(c net.Conn, profiles []loginProfile, password string, rsaPublicKey []byte, hostname string) (*clientConn, error) {
if len(rsaPublicKey) == 0 {
return nil, fmt.Errorf("empty rsa public key")
}
if len(profiles) == 0 {
return nil, fmt.Errorf("empty profiles")
}
paddingSchedule, err := newClientPaddingSchedule2612()
if err != nil {
return nil, fmt.Errorf("select padding profile: %w", err)
}
return &clientConn{
reader: bufio.NewReader(c),
writer: c,
c: c,
state: clientStateHandshake,
handshakeLock: sync.Mutex{},
usernames: usernames,
password: password,
rsaPublicKey: rsaPublicKey,
hostname: hostname,
reader: bufio.NewReader(c),
writer: c,
c: c,
state: clientStateHandshake,
handshakeLock: sync.Mutex{},
profiles: profiles,
password: password,
rsaPublicKey: rsaPublicKey,
hostname: hostname,
paddingSchedule: paddingSchedule,
deadlines: newConnectionDeadlines(c),
}, nil
}
@@ -63,12 +75,10 @@ func (c *clientConn) handshake() error {
return nil
}
// Handshake timeout
err := c.c.SetDeadline(time.Now().Add(time.Second * 30))
if err != nil {
if err := c.deadlines.beginHandshake(); err != nil {
return fmt.Errorf("set deadline: %w", err)
}
defer c.c.SetDeadline(time.Time{})
defer func() { _ = c.deadlines.endHandshake() }()
var (
protocolVersion Varint = Varint(775)
@@ -95,16 +105,14 @@ func (c *clientConn) handshake() error {
}
// Login Start
var (
username string
offlineUUID UUID
)
randomProfile, err := rand.Int(rand.Reader, big.NewInt(int64(len(c.profiles))))
if err != nil {
return fmt.Errorf("select profile: %w", err)
}
selectedProfile := c.profiles[randomProfile.Int64()]
username := String(selectedProfile.Username)
randomUsername, _ := rand.Int(rand.Reader, big.NewInt(int64(len(c.usernames))))
username = c.usernames[randomUsername.Int64()]
generateOfflineUUID(&offlineUUID, string(username))
err = writePacket(c.writer, 0x00, new(String(username)), &offlineUUID)
err = writePacket(c.writer, 0x00, &username, &selectedProfile.UUID)
if err != nil {
return fmt.Errorf("write login start: %w", err)
}
@@ -145,7 +153,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 {
@@ -181,7 +191,48 @@ 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 finished: %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 finished packet id: %d", pkt.packetID)
}
receivedProfile, err := readLoginSuccess(pkt)
if err != nil {
return fmt.Errorf("read login finished fields: %w", err)
}
if receivedProfile != selectedProfile {
return fmt.Errorf("login profile mismatch")
}
loginAcknowledgedLength, err := writePacketWithLength(c.writer, 0x03)
if err != nil {
return fmt.Errorf("write login acknowledged: %w", err)
}
if err = runPaddingSchedule(c.reader, c.writer, true, loginAcknowledgedLength, c.paddingSchedule); err != nil {
return fmt.Errorf("run startup padding: %w", err)
}
packet := newPacketStream(c.reader, c.writer, true)
c.lifecycleMu.Lock()
if c.closed {
c.lifecycleMu.Unlock()
packet.Stop()
return net.ErrClosed
}
c.packet = packet
c.reader = packet
c.writer = packet
c.state = clientStateProxy
c.lifecycleMu.Unlock()
return nil
}
@@ -205,6 +256,13 @@ func (c *clientConn) Write(b []byte) (int, error) {
}
func (c *clientConn) Close() error {
c.lifecycleMu.Lock()
c.closed = true
packet := c.packet
c.lifecycleMu.Unlock()
if packet != nil {
packet.Stop()
}
return c.c.Close()
}
@@ -217,20 +275,13 @@ func (c *clientConn) RemoteAddr() net.Addr {
}
func (c *clientConn) SetDeadline(t time.Time) error {
return c.c.SetDeadline(t)
return c.deadlines.setDeadline(t)
}
func (c *clientConn) SetReadDeadline(t time.Time) error {
return c.c.SetReadDeadline(t)
return c.deadlines.setReadDeadline(t)
}
func (c *clientConn) SetWriteDeadline(t time.Time) error {
return c.c.SetWriteDeadline(t)
}
func generateOfflineUUID(uuid *UUID, username string) {
h := sha256.Sum256([]byte("OfflinePlayer:" + username))
copy(uuid[:], h[:16])
uuid[6] = (uuid[6] & 0x0f) | 0x30 // UUID version 3
uuid[8] = (uuid[8] & 0x3f) | 0x80 // UUID variant
return c.deadlines.setWriteDeadline(t)
}
+10 -2
View File
@@ -9,7 +9,11 @@ func (c *Config) TCP() {
}
func (c *Config) WrapConnClient(conn net.Conn) (net.Conn, error) {
cc, err := newClientConn(conn, c.Usernames, c.Password, c.RsaPublicKey, c.Hostname)
profiles, err := profilesFromConfig(c.Profiles)
if err != nil {
return nil, fmt.Errorf("minecraft finalmask: %w", err)
}
cc, err := newClientConn(conn, profiles, c.Password, c.RsaPublicKey, c.Hostname)
if err != nil {
return nil, fmt.Errorf("minecraft finalmask: %w", err)
}
@@ -18,7 +22,11 @@ func (c *Config) WrapConnClient(conn net.Conn) (net.Conn, error) {
}
func (c *Config) WrapConnServer(conn net.Conn) (net.Conn, error) {
cc, err := wrapConnServer(conn, c.Password, c.RsaPrivateKey, c.RsaPublicKey)
profiles, err := profilesFromConfig(c.Profiles)
if err != nil {
return nil, fmt.Errorf("minecraft finalmask: %w", err)
}
cc, err := wrapConnServer(conn, profiles, c.Password, c.RsaPrivateKey, c.RsaPublicKey)
if err != nil {
return nil, fmt.Errorf("minecraft finalmask: %w", err)
}
+101 -23
View File
@@ -21,20 +21,91 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type Profile struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Resolve the UUID from https://api.mojang.com/users/profiles/minecraft/{username}.
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
Uuid []byte `protobuf:"bytes,2,opt,name=uuid,proto3" json:"uuid,omitempty"`
// Copy the signed textures property returned by
// https://sessionserver.mojang.com/session/minecraft/profile/{uuid}?unsigned=false.
TexturesValue string `protobuf:"bytes,3,opt,name=textures_value,json=texturesValue,proto3" json:"textures_value,omitempty"`
TexturesSignature string `protobuf:"bytes,4,opt,name=textures_signature,json=texturesSignature,proto3" json:"textures_signature,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Profile) Reset() {
*x = Profile{}
mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Profile) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Profile) ProtoMessage() {}
func (x *Profile) ProtoReflect() protoreflect.Message {
mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Profile.ProtoReflect.Descriptor instead.
func (*Profile) Descriptor() ([]byte, []int) {
return file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP(), []int{0}
}
func (x *Profile) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *Profile) GetUuid() []byte {
if x != nil {
return x.Uuid
}
return nil
}
func (x *Profile) GetTexturesValue() string {
if x != nil {
return x.TexturesValue
}
return ""
}
func (x *Profile) GetTexturesSignature() string {
if x != nil {
return x.TexturesSignature
}
return ""
}
type Config struct {
state protoimpl.MessageState `protogen:"open.v1"`
Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"`
Usernames []string `protobuf:"bytes,2,rep,name=usernames,proto3" json:"usernames,omitempty"`
RsaPrivateKey []byte `protobuf:"bytes,8,opt,name=rsa_private_key,json=rsaPrivateKey,proto3" json:"rsa_private_key,omitempty"`
RsaPublicKey []byte `protobuf:"bytes,9,opt,name=rsa_public_key,json=rsaPublicKey,proto3" json:"rsa_public_key,omitempty"`
Hostname string `protobuf:"bytes,10,opt,name=hostname,proto3" json:"hostname,omitempty"`
Profiles []*Profile `protobuf:"bytes,11,rep,name=profiles,proto3" json:"profiles,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Config) Reset() {
*x = Config{}
mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[0]
mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -46,7 +117,7 @@ func (x *Config) String() string {
func (*Config) ProtoMessage() {}
func (x *Config) ProtoReflect() protoreflect.Message {
mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[0]
mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -59,7 +130,7 @@ func (x *Config) ProtoReflect() protoreflect.Message {
// Deprecated: Use Config.ProtoReflect.Descriptor instead.
func (*Config) Descriptor() ([]byte, []int) {
return file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP(), []int{0}
return file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP(), []int{1}
}
func (x *Config) GetPassword() string {
@@ -69,13 +140,6 @@ func (x *Config) GetPassword() string {
return ""
}
func (x *Config) GetUsernames() []string {
if x != nil {
return x.Usernames
}
return nil
}
func (x *Config) GetRsaPrivateKey() []byte {
if x != nil {
return x.RsaPrivateKey
@@ -97,18 +161,30 @@ func (x *Config) GetHostname() string {
return ""
}
func (x *Config) GetProfiles() []*Profile {
if x != nil {
return x.Profiles
}
return nil
}
var File_transport_internet_finalmask_xmc_config_proto protoreflect.FileDescriptor
const file_transport_internet_finalmask_xmc_config_proto_rawDesc = "" +
"\n" +
"-transport/internet/finalmask/xmc/config.proto\x12%xray.transport.internet.finalmask.xmc\"\xac\x01\n" +
"-transport/internet/finalmask/xmc/config.proto\x12%xray.transport.internet.finalmask.xmc\"\x8f\x01\n" +
"\aProfile\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12\x12\n" +
"\x04uuid\x18\x02 \x01(\fR\x04uuid\x12%\n" +
"\x0etextures_value\x18\x03 \x01(\tR\rtexturesValue\x12-\n" +
"\x12textures_signature\x18\x04 \x01(\tR\x11texturesSignature\"\xe0\x01\n" +
"\x06Config\x12\x1a\n" +
"\bpassword\x18\x01 \x01(\tR\bpassword\x12\x1c\n" +
"\tusernames\x18\x02 \x03(\tR\tusernames\x12&\n" +
"\bpassword\x18\x01 \x01(\tR\bpassword\x12&\n" +
"\x0frsa_private_key\x18\b \x01(\fR\rrsaPrivateKey\x12$\n" +
"\x0ersa_public_key\x18\t \x01(\fR\frsaPublicKey\x12\x1a\n" +
"\bhostname\x18\n" +
" \x01(\tR\bhostnameB\x91\x01\n" +
" \x01(\tR\bhostname\x12J\n" +
"\bprofiles\x18\v \x03(\v2..xray.transport.internet.finalmask.xmc.ProfileR\bprofilesJ\x04\b\x02\x10\x03B\x91\x01\n" +
")com.xray.transport.internet.finalmask.xmcP\x01Z:github.com/xtls/xray-core/transport/internet/finalmask/xmc\xaa\x02%Xray.Transport.Internet.Finalmask.XMCb\x06proto3"
var (
@@ -123,16 +199,18 @@ func file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP() []byte {
return file_transport_internet_finalmask_xmc_config_proto_rawDescData
}
var file_transport_internet_finalmask_xmc_config_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
var file_transport_internet_finalmask_xmc_config_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_transport_internet_finalmask_xmc_config_proto_goTypes = []any{
(*Config)(nil), // 0: xray.transport.internet.finalmask.xmc.Config
(*Profile)(nil), // 0: xray.transport.internet.finalmask.xmc.Profile
(*Config)(nil), // 1: xray.transport.internet.finalmask.xmc.Config
}
var file_transport_internet_finalmask_xmc_config_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type
0, // [0:0] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
0, // 0: xray.transport.internet.finalmask.xmc.Config.profiles:type_name -> xray.transport.internet.finalmask.xmc.Profile
1, // [1:1] is the sub-list for method output_type
1, // [1:1] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_transport_internet_finalmask_xmc_config_proto_init() }
@@ -146,7 +224,7 @@ func file_transport_internet_finalmask_xmc_config_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_transport_internet_finalmask_xmc_config_proto_rawDesc), len(file_transport_internet_finalmask_xmc_config_proto_rawDesc)),
NumEnums: 0,
NumMessages: 1,
NumMessages: 2,
NumExtensions: 0,
NumServices: 0,
},
+11 -1
View File
@@ -6,11 +6,21 @@ option go_package = "github.com/xtls/xray-core/transport/internet/finalmask/xmc"
option java_package = "com.xray.transport.internet.finalmask.xmc";
option java_multiple_files = true;
message Profile {
// Resolve the UUID from https://api.mojang.com/users/profiles/minecraft/{username}.
string username = 1;
bytes uuid = 2;
// Copy the signed textures property returned by
// https://sessionserver.mojang.com/session/minecraft/profile/{uuid}?unsigned=false.
string textures_value = 3;
string textures_signature = 4;
}
message Config {
string password = 1;
repeated string usernames = 2;
reserved 2;
bytes rsa_private_key = 8;
bytes rsa_public_key = 9;
string hostname = 10;
repeated Profile profiles = 11;
}
@@ -0,0 +1,85 @@
package xmc
import (
"net"
"sync"
"time"
)
const handshakeTimeout = 2 * time.Minute
type connectionDeadlines struct {
mu sync.Mutex
c net.Conn
read time.Time
write time.Time
handshake time.Time
}
func newConnectionDeadlines(c net.Conn) *connectionDeadlines {
return &connectionDeadlines{c: c}
}
func (d *connectionDeadlines) beginHandshake() error {
d.mu.Lock()
defer d.mu.Unlock()
d.handshake = time.Now().Add(handshakeTimeout)
if err := d.applyLocked(); err != nil {
d.handshake = time.Time{}
_ = d.applyLocked()
return err
}
return nil
}
func (d *connectionDeadlines) endHandshake() error {
d.mu.Lock()
defer d.mu.Unlock()
d.handshake = time.Time{}
return d.applyLocked()
}
func (d *connectionDeadlines) setDeadline(t time.Time) error {
d.mu.Lock()
defer d.mu.Unlock()
d.read = t
d.write = t
return d.applyLocked()
}
func (d *connectionDeadlines) setReadDeadline(t time.Time) error {
d.mu.Lock()
defer d.mu.Unlock()
d.read = t
return d.c.SetReadDeadline(earlierDeadline(d.read, d.handshake))
}
func (d *connectionDeadlines) setWriteDeadline(t time.Time) error {
d.mu.Lock()
defer d.mu.Unlock()
d.write = t
return d.c.SetWriteDeadline(earlierDeadline(d.write, d.handshake))
}
func (d *connectionDeadlines) applyLocked() error {
if err := d.c.SetReadDeadline(earlierDeadline(d.read, d.handshake)); err != nil {
return err
}
return d.c.SetWriteDeadline(earlierDeadline(d.write, d.handshake))
}
func earlierDeadline(user, internal time.Time) time.Time {
if internal.IsZero() {
return user
}
if user.IsZero() || internal.Before(user) {
return internal
}
return user
}
@@ -0,0 +1,81 @@
package xmc
import (
"net"
"sync"
"testing"
"time"
)
func TestConnectionDeadlinesRestoreCallerValues(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
recording := &deadlineRecordingConn{Conn: client}
deadlines := newConnectionDeadlines(recording)
callerDeadline := time.Now().Add(10 * time.Minute)
if err := deadlines.setDeadline(callerDeadline); err != nil {
t.Fatal(err)
}
if err := deadlines.beginHandshake(); err != nil {
t.Fatal(err)
}
read, write := recording.currentDeadlines()
if !read.Before(callerDeadline) || !write.Before(callerDeadline) {
t.Fatalf("handshake deadlines = %s/%s, caller = %s", read, write, callerDeadline)
}
shortReadDeadline := time.Now().Add(time.Second)
if err := deadlines.setReadDeadline(shortReadDeadline); err != nil {
t.Fatal(err)
}
read, _ = recording.currentDeadlines()
if !read.Equal(shortReadDeadline) {
t.Fatalf("read deadline = %s, want %s", read, shortReadDeadline)
}
if err := deadlines.endHandshake(); err != nil {
t.Fatal(err)
}
read, write = recording.currentDeadlines()
if !read.Equal(shortReadDeadline) || !write.Equal(callerDeadline) {
t.Fatalf("restored deadlines = %s/%s, want %s/%s", read, write, shortReadDeadline, callerDeadline)
}
}
type deadlineRecordingConn struct {
net.Conn
mu sync.Mutex
read time.Time
write time.Time
}
func (c *deadlineRecordingConn) SetDeadline(t time.Time) error {
c.mu.Lock()
c.read = t
c.write = t
c.mu.Unlock()
return c.Conn.SetDeadline(t)
}
func (c *deadlineRecordingConn) SetReadDeadline(t time.Time) error {
c.mu.Lock()
c.read = t
c.mu.Unlock()
return c.Conn.SetReadDeadline(t)
}
func (c *deadlineRecordingConn) SetWriteDeadline(t time.Time) error {
c.mu.Lock()
c.write = t
c.mu.Unlock()
return c.Conn.SetWriteDeadline(t)
}
func (c *deadlineRecordingConn) currentDeadlines() (time.Time, time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
return c.read, c.write
}
@@ -2,10 +2,16 @@ package xmc
import (
"bytes"
"crypto/sha256"
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
"testing"
"time"
)
func deriveTestRSAKey(t *testing.T, password string) ([]byte, []byte) {
@@ -24,6 +30,19 @@ func deriveTestRSAKey(t *testing.T, password string) ([]byte, []byte) {
return x509.MarshalPKCS1PrivateKey(key), publicKey
}
func testLoginProfile(username string) loginProfile {
profile := loginProfile{
Username: username,
TexturesValue: strings.Repeat("texture-value-", 40),
TexturesSignature: strings.Repeat("texture-signature-", 24),
}
digest := sha256.Sum256([]byte(username))
copy(profile.UUID[:], digest[:16])
profile.UUID[6] = (profile.UUID[6] & 0x0f) | 0x40
profile.UUID[8] = (profile.UUID[8] & 0x3f) | 0x80
return profile
}
func TestHandshakeSuccess(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -32,7 +51,7 @@ func TestHandshakeSuccess(t *testing.T) {
defer ln.Close()
password := "super-secure-shared-key-12345"
usernames := []string{"test_user"}
profiles := []loginProfile{testLoginProfile("test_user")}
privateKey, publicKey := deriveTestRSAKey(t, password)
go func() {
@@ -42,7 +61,7 @@ func TestHandshakeSuccess(t *testing.T) {
}
defer rawConn.Close()
server, err := wrapConnServer(rawConn, password, privateKey, publicKey)
server, err := wrapConnServer(rawConn, profiles, password, privateKey, publicKey)
if err != nil {
t.Errorf("failed to wrap server: %v", err)
return
@@ -73,7 +92,7 @@ func TestHandshakeSuccess(t *testing.T) {
}
defer clientRaw.Close()
client, err := newClientConn(clientRaw, usernames, password, publicKey, "localhost")
client, err := newClientConn(clientRaw, profiles, password, publicKey, "localhost")
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
@@ -103,7 +122,7 @@ func TestHandshakePasswordMismatch(t *testing.T) {
clientPassword := "client-secret-123"
serverPassword := "server-secret-456"
usernames := []string{"test_user"}
profiles := []loginProfile{testLoginProfile("test_user")}
serverPrivateKey, serverPublicKey := deriveTestRSAKey(t, serverPassword)
var wg sync.WaitGroup
@@ -117,7 +136,7 @@ func TestHandshakePasswordMismatch(t *testing.T) {
}
defer rawConn.Close()
server, err := wrapConnServer(rawConn, serverPassword, serverPrivateKey, serverPublicKey)
server, err := wrapConnServer(rawConn, profiles, serverPassword, serverPrivateKey, serverPublicKey)
if err != nil {
// Wrapping is synchronous and shouldn't fail initially simply because key derivation works with any string
t.Logf("wrapped server: %v", err)
@@ -139,20 +158,233 @@ func TestHandshakePasswordMismatch(t *testing.T) {
}
defer clientRaw.Close()
client, err := newClientConn(clientRaw, usernames, clientPassword, serverPublicKey, "localhost")
client, err := newClientConn(clientRaw, profiles, clientPassword, serverPublicKey, "localhost")
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
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
t.Log("Handshake mismatch tested")
}
func TestHandshakeNetPipeWithKeepAlive(t *testing.T) {
clientRaw, serverRaw := net.Pipe()
defer clientRaw.Close()
defer serverRaw.Close()
const password = "net-pipe-shared-key"
profiles := []loginProfile{testLoginProfile("pipe_user")}
privateKey, publicKey := deriveTestRSAKey(t, password)
serverDone := make(chan error, 1)
go func() {
server, err := wrapConnServer(serverRaw, profiles, password, privateKey, publicKey)
if err != nil {
serverDone <- err
return
}
request := make([]byte, len("hello server"))
if _, err = io.ReadFull(server, request); err != nil {
serverDone <- fmt.Errorf("read request: %w", err)
return
}
if string(request) != "hello server" {
serverDone <- fmt.Errorf("unexpected request: %q", request)
return
}
followupDone := make(chan error, 1)
go func() {
followup := make([]byte, len("after keepalive"))
_, readErr := io.ReadFull(server, followup)
if readErr == nil && string(followup) != "after keepalive" {
readErr = fmt.Errorf("unexpected followup: %q", followup)
}
followupDone <- readErr
}()
if err = server.packet.writeKeepAlive(Long(42)); err != nil {
serverDone <- fmt.Errorf("write keep-alive: %w", err)
return
}
if _, err = server.Write([]byte("hello client")); err != nil {
serverDone <- fmt.Errorf("write response: %w", err)
return
}
serverDone <- <-followupDone
}()
client, err := newClientConn(clientRaw, profiles, password, publicKey, "localhost")
if err != nil {
t.Fatal(err)
}
if _, err = client.Write([]byte("hello server")); err != nil {
t.Fatalf("write request: %v", err)
}
response := make([]byte, len("hello client"))
if _, err = io.ReadFull(client, response); err != nil {
t.Fatalf("read response: %v", err)
}
if string(response) != "hello client" {
t.Fatalf("unexpected response: %q", response)
}
if _, err = client.Write([]byte("after keepalive")); err != nil {
t.Fatalf("write followup: %v", err)
}
select {
case err = <-serverDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("net.Pipe handshake timed out")
}
}
func TestStatusQueryUnaffected(t *testing.T) {
clientRaw, serverRaw := net.Pipe()
defer clientRaw.Close()
defer serverRaw.Close()
const password = "status-shared-key"
profiles := []loginProfile{testLoginProfile("status_user")}
privateKey, publicKey := deriveTestRSAKey(t, password)
serverDone := make(chan error, 1)
go func() {
server, err := wrapConnServer(serverRaw, profiles, password, privateKey, publicKey)
if err == nil {
err = server.handshake()
}
serverDone <- err
}()
protocolVersion := Varint(775)
serverAddress := String("localhost")
serverPort := UnsignedShort(25565)
nextState := Varint(1)
if err := writePacket(clientRaw, 0x00, &protocolVersion, &serverAddress, &serverPort, &nextState); err != nil {
t.Fatal(err)
}
if err := writePacket(clientRaw, 0x00); err != nil {
t.Fatal(err)
}
response, err := readPacket(clientRaw)
if err != nil {
t.Fatal(err)
}
if response.packetID != 0x00 {
t.Fatalf("status packet id = %d", response.packetID)
}
var responseJSON String
if err = response.readFields(&responseJSON); err != nil {
t.Fatal(err)
}
if string(responseJSON) != statusResponse {
t.Fatalf("status response = %q", responseJSON)
}
payload := Long(0x0102030405060708)
if err = writePacket(clientRaw, 0x01, &payload); err != nil {
t.Fatal(err)
}
pong, err := readPacket(clientRaw)
if err != nil {
t.Fatal(err)
}
var receivedPayload Long
if pong.packetID != 0x01 {
t.Fatalf("pong packet id = %d", pong.packetID)
}
if err = pong.readFields(&receivedPayload); err != nil {
t.Fatal(err)
}
if receivedPayload != payload {
t.Fatalf("pong payload = %x", receivedPayload)
}
select {
case err = <-serverDone:
if err == nil || !strings.Contains(err.Error(), "ping") {
t.Fatalf("server error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("status handshake timed out")
}
}
func TestClientHandshakeHonorsCallerDeadline(t *testing.T) {
clientRaw, serverRaw := net.Pipe()
defer clientRaw.Close()
defer serverRaw.Close()
const password = "deadline-shared-key"
profiles := []loginProfile{testLoginProfile("deadline_user")}
_, publicKey := deriveTestRSAKey(t, password)
client, err := newClientConn(clientRaw, profiles, password, publicKey, "localhost")
if err != nil {
t.Fatal(err)
}
if err = client.SetDeadline(time.Now().Add(30 * time.Millisecond)); err != nil {
t.Fatal(err)
}
started := time.Now()
_, err = client.Write([]byte("blocked"))
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("error = %v, want network timeout", err)
}
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
t.Fatalf("caller deadline took %s", elapsed)
}
}
func TestClientCloseInterruptsHandshake(t *testing.T) {
clientRaw, serverRaw := net.Pipe()
defer serverRaw.Close()
const password = "close-shared-key"
profiles := []loginProfile{testLoginProfile("close_user")}
_, publicKey := deriveTestRSAKey(t, password)
client, err := newClientConn(clientRaw, profiles, password, publicKey, "localhost")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
_, writeErr := client.Write([]byte("blocked"))
done <- writeErr
}()
time.Sleep(20 * time.Millisecond)
if err = client.Close(); err != nil {
t.Fatal(err)
}
select {
case err = <-done:
if err == nil {
t.Fatal("handshake unexpectedly succeeded after close")
}
case <-time.After(time.Second):
t.Fatal("close did not interrupt handshake")
}
}
func TestValidateLoginAcknowledgedPacketRejectsData(t *testing.T) {
if err := validateLoginAcknowledgedPacket(&mcPacket{packetID: 0x03}); err != nil {
t.Fatalf("valid login acknowledged packet: %v", err)
}
if err := validateLoginAcknowledgedPacket(&mcPacket{packetID: 0x03, data: []byte{0x00}}); err == nil {
t.Fatal("login acknowledged packet with trailing data was accepted")
}
}
@@ -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
}
}
}
@@ -0,0 +1,102 @@
package xmc
import (
"bytes"
"io"
"net"
"testing"
)
func TestPacketStreamUsesPlainFraming(t *testing.T) {
payload := []byte("hello")
var wire bytes.Buffer
stream := newPacketStream(bytes.NewReader(nil), &wire, true)
written, err := stream.Write(payload)
if err != nil {
t.Fatalf("write payload: %v", err)
}
if written != len(payload) {
t.Fatalf("written = %d, want %d", written, len(payload))
}
wantOutbound := []byte{0x0f, 0x02, 0x08, 'x', 'm', 'c', ':', 'd', 'a', 't', 'a', 'h', 'e', 'l', 'l', 'o'}
if !bytes.Equal(wire.Bytes(), wantOutbound) {
t.Fatalf("wire frame = %x, want %x", wire.Bytes(), wantOutbound)
}
wantInbound := append([]byte(nil), wantOutbound...)
wantInbound[1] = configurationClientboundCustomPayload
reader := newPacketStream(bytes.NewReader(wantInbound), io.Discard, true)
got := make([]byte, len(payload))
if _, err = io.ReadFull(reader, got); err != nil {
t.Fatalf("read payload: %v", err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("payload = %q, want %q", got, payload)
}
}
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)
profiles := []loginProfile{testLoginProfile("packet_user")}
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, profiles, 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, profiles, 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)
}
}
+432
View File
@@ -0,0 +1,432 @@
package xmc
import (
"bytes"
"crypto/rand"
"fmt"
"io"
"math/big"
"time"
)
type paddingDirection uint8
const (
paddingClientToServer paddingDirection = iota + 1
paddingServerToClient
paddingBufferLength = 16 * 1024
maxPaddingChunkLength = 48 * 1024
maxPaddingTurnLength = 8 * 1024 * 1024
)
type paddingVariant struct {
chunks []int
delays []paddingDelayRange
}
type paddingDelayRange struct {
min time.Duration
max time.Duration
}
type paddingTurn struct {
direction paddingDirection
minLength int
maxLength int
variants []paddingVariant
startDelay paddingDelayRange
chunkDelay paddingDelayRange
writeChunkMinLength int
writeChunkLength int
sendMinLength int
sendMaxLength int
sendVariants []int
}
func runPaddingSchedule(reader io.Reader, writer io.Writer, isClient bool, firstTurnPrefixLength int, schedule []paddingTurn) error {
if err := validatePaddingSchedule(schedule, firstTurnPrefixLength); err != nil {
return err
}
var writeBuffer []byte
for i, turn := range schedule {
prefixLength := 0
if i == 0 {
prefixLength = firstTurnPrefixLength
}
localSends := isClient == (turn.direction == paddingClientToServer)
if localSends {
if err := writePaddingTurnWithBuffer(writer, turn, prefixLength, time.Sleep, &writeBuffer); err != nil {
return fmt.Errorf("write padding turn %d: %w", i, err)
}
continue
}
if err := readPaddingTurn(reader, turn, prefixLength); err != nil {
return fmt.Errorf("read padding turn %d: %w", i, err)
}
}
return nil
}
func validatePaddingSchedule(schedule []paddingTurn, firstTurnPrefixLength int) error {
if len(schedule) == 0 {
return fmt.Errorf("empty padding schedule")
}
if firstTurnPrefixLength < 0 {
return fmt.Errorf("negative first turn prefix length: %d", firstTurnPrefixLength)
}
if firstTurnPrefixLength > 0 && schedule[0].direction != paddingClientToServer {
return fmt.Errorf("first prefixed padding turn is not client-to-server")
}
for i, turn := range schedule {
if turn.direction != paddingClientToServer && turn.direction != paddingServerToClient {
return fmt.Errorf("padding turn %d has invalid direction: %d", i, turn.direction)
}
if err := validatePaddingDelayRange(turn.startDelay); err != nil {
return fmt.Errorf("padding turn %d has an invalid start delay: %w", i, err)
}
if err := validatePaddingDelayRange(turn.chunkDelay); err != nil {
return fmt.Errorf("padding turn %d has an invalid chunk delay: %w", i, err)
}
if turn.writeChunkMinLength < 0 || turn.writeChunkLength < turn.writeChunkMinLength || turn.writeChunkLength > maxPaddingChunkLength {
return fmt.Errorf("padding turn %d has an invalid write chunk range: %d-%d", i, turn.writeChunkMinLength, turn.writeChunkLength)
}
if len(turn.variants) > 0 && turn.writeChunkLength != 0 {
return fmt.Errorf("padding turn %d combines variants with generated write chunks", i)
}
minLength, maxLength, err := paddingTurnBounds(turn)
if err != nil {
return fmt.Errorf("padding turn %d: %w", i, err)
}
hasSendRange := turn.sendMinLength != 0 || turn.sendMaxLength != 0
if hasSendRange {
if len(turn.variants) > 0 {
return fmt.Errorf("padding turn %d combines variants with a send range", i)
}
if turn.sendMinLength < minLength || turn.sendMaxLength < turn.sendMinLength || turn.sendMaxLength > maxLength {
return fmt.Errorf("padding turn %d has an invalid send range: %d-%d", i, turn.sendMinLength, turn.sendMaxLength)
}
}
if i == 0 && minLength-firstTurnPrefixLength < 1 {
return fmt.Errorf("padding turn 0 is too short for %d prefix bytes", firstTurnPrefixLength)
}
if i == 0 && len(turn.variants) > 0 {
for j, variant := range turn.variants {
if _, _, err = trimPaddingPrefix(variant, firstTurnPrefixLength); err != nil {
return fmt.Errorf("padding turn 0 variant %d: %w", j, err)
}
}
}
if i > 0 && turn.direction == schedule[i-1].direction {
return fmt.Errorf("padding turns %d and %d have the same direction", i-1, i)
}
}
return nil
}
func writePaddingTurn(w io.Writer, turn paddingTurn, prefixLength int) error {
return writePaddingTurnWithSleep(w, turn, prefixLength, time.Sleep)
}
func writePaddingTurnWithSleep(w io.Writer, turn paddingTurn, prefixLength int, sleep func(time.Duration)) error {
return writePaddingTurnWithBuffer(w, turn, prefixLength, sleep, nil)
}
func writePaddingTurnWithBuffer(w io.Writer, turn paddingTurn, prefixLength int, sleep func(time.Duration), reusableBuffer *[]byte) error {
startDelay, err := randomPaddingDelay(turn.startDelay)
if err != nil {
return fmt.Errorf("select padding start delay: %w", err)
}
if startDelay > 0 {
sleep(startDelay)
}
targetLength, chunks, delays, err := selectPaddingVariant(turn, prefixLength)
if err != nil {
return err
}
recordLength := targetLength - prefixLength
if recordLength < 1 {
return fmt.Errorf("target length %d leaves an invalid record length %d", targetLength, recordLength)
}
encodedLength := Varint(recordLength)
var header bytes.Buffer
if err = encodedLength.writeTo(&header); err != nil {
return fmt.Errorf("write padding header: %w", err)
}
if len(chunks) == 0 {
writeChunkLength := turn.writeChunkLength
if writeChunkLength == 0 {
writeChunkLength = paddingBufferLength
} else if turn.writeChunkMinLength > 0 {
writeChunkLength, err = randomPaddingTarget(turn.writeChunkMinLength, writeChunkLength)
if err != nil {
return fmt.Errorf("select padding write chunk length: %w", err)
}
}
chunks = defaultPaddingChunks(recordLength, writeChunkLength)
delays = make([]paddingDelayRange, len(chunks))
for i := 1; i < len(delays); i++ {
delays[i] = turn.chunkDelay
}
}
if chunks[0] < header.Len() {
return fmt.Errorf("first padding chunk %d is shorter than header %d", chunks[0], header.Len())
}
maxChunkLength := 0
for _, chunkLength := range chunks {
if chunkLength < 1 || chunkLength > maxPaddingChunkLength {
return fmt.Errorf("invalid padding chunk length: %d", chunkLength)
}
maxChunkLength = max(maxChunkLength, chunkLength)
}
var buffer []byte
if reusableBuffer == nil {
buffer = make([]byte, maxChunkLength)
} else {
if cap(*reusableBuffer) < maxChunkLength {
*reusableBuffer = make([]byte, maxChunkLength)
}
buffer = (*reusableBuffer)[:maxChunkLength]
clear(buffer)
}
copy(buffer, header.Bytes())
written := 0
for i, chunkLength := range chunks {
if i < len(delays) {
delay, delayErr := randomPaddingDelay(delays[i])
if delayErr != nil {
return fmt.Errorf("select padding chunk %d delay: %w", i, delayErr)
}
if delay > 0 {
sleep(delay)
}
}
if err = writeFull(w, buffer[:chunkLength]); err != nil {
return fmt.Errorf("write padding chunk %d: %w", i, err)
}
written += chunkLength
if i == 0 {
clear(buffer[:header.Len()])
}
}
if written != recordLength {
return fmt.Errorf("padding chunks total %d, want %d", written, recordLength)
}
return nil
}
func readPaddingTurn(r io.Reader, turn paddingTurn, prefixLength int) error {
encodedLength, headerLength, err := readVarintWithLength(r)
if err != nil {
return fmt.Errorf("read padding header: %w", err)
}
recordLength := int(encodedLength)
if recordLength < headerLength || recordLength > maxPaddingTurnLength {
return fmt.Errorf("invalid padding record length: %d", recordLength)
}
totalLength := prefixLength + recordLength
if !paddingTurnAcceptsLength(turn, totalLength) {
if len(turn.variants) > 0 {
return fmt.Errorf("padding turn length %d is not an allowed variant", totalLength)
}
return fmt.Errorf("padding turn length %d is outside %d-%d", totalLength, turn.minLength, turn.maxLength)
}
var buffer [paddingBufferLength]byte
remaining := recordLength - headerLength
for remaining > 0 {
chunkLength := min(remaining, len(buffer))
if _, err := io.ReadFull(r, buffer[:chunkLength]); err != nil {
return fmt.Errorf("read padding body: %w", err)
}
remaining -= chunkLength
}
return nil
}
func selectPaddingVariant(turn paddingTurn, prefixLength int) (int, []int, []paddingDelayRange, error) {
if len(turn.variants) == 0 {
minimum, maximum := turn.minLength, turn.maxLength
if turn.sendMinLength != 0 || turn.sendMaxLength != 0 {
minimum, maximum = turn.sendMinLength, turn.sendMaxLength
}
targetLength, err := randomPaddingTarget(minimum, maximum)
return targetLength, nil, nil, err
}
indices := turn.sendVariants
if len(indices) == 0 {
indices = make([]int, len(turn.variants))
for i := range indices {
indices[i] = i
}
}
selected, err := randomPaddingIndex(len(indices))
if err != nil {
return 0, nil, nil, err
}
variantIndex := indices[selected]
if variantIndex < 0 || variantIndex >= len(turn.variants) {
return 0, nil, nil, fmt.Errorf("invalid send variant index: %d", variantIndex)
}
variant := turn.variants[variantIndex]
targetLength := paddingVariantLength(variant)
chunks, delays, err := trimPaddingPrefix(variant, prefixLength)
if err != nil {
return 0, nil, nil, err
}
return targetLength, chunks, delays, nil
}
func trimPaddingPrefix(variant paddingVariant, prefixLength int) ([]int, []paddingDelayRange, error) {
remainingPrefix := prefixLength
firstChunk := 0
for firstChunk < len(variant.chunks) && remainingPrefix > 0 {
chunkLength := variant.chunks[firstChunk]
if remainingPrefix < chunkLength {
return nil, nil, fmt.Errorf("prefix length %d splits chunk %d", prefixLength, firstChunk)
}
remainingPrefix -= chunkLength
firstChunk++
}
if remainingPrefix != 0 || firstChunk == len(variant.chunks) {
return nil, nil, fmt.Errorf("prefix length %d leaves no padding record", prefixLength)
}
chunks := append([]int(nil), variant.chunks[firstChunk:]...)
delays := make([]paddingDelayRange, len(chunks))
if len(variant.delays) > 0 {
copy(delays, variant.delays[firstChunk:])
}
return chunks, delays, nil
}
func defaultPaddingChunks(recordLength, writeChunkLength int) []int {
chunks := make([]int, 0, (recordLength+writeChunkLength-1)/writeChunkLength)
for remaining := recordLength; remaining > 0; {
chunkLength := min(remaining, writeChunkLength)
chunks = append(chunks, chunkLength)
remaining -= chunkLength
}
return chunks
}
func paddingTurnBounds(turn paddingTurn) (int, int, error) {
if len(turn.variants) == 0 {
if turn.minLength < 1 || turn.maxLength < turn.minLength || turn.maxLength > maxPaddingTurnLength {
return 0, 0, fmt.Errorf("invalid range: %d-%d", turn.minLength, turn.maxLength)
}
return turn.minLength, turn.maxLength, nil
}
if turn.minLength != 0 || turn.maxLength != 0 {
return 0, 0, fmt.Errorf("variants cannot be combined with a length range")
}
minLength := maxPaddingTurnLength + 1
maxLength := 0
for i, variant := range turn.variants {
if len(variant.chunks) == 0 {
return 0, 0, fmt.Errorf("variant %d has no chunks", i)
}
if len(variant.delays) != 0 && len(variant.delays) != len(variant.chunks) {
return 0, 0, fmt.Errorf("variant %d has %d chunks and %d delays", i, len(variant.chunks), len(variant.delays))
}
for j, chunkLength := range variant.chunks {
if chunkLength < 1 || chunkLength > maxPaddingChunkLength {
return 0, 0, fmt.Errorf("variant %d chunk %d has invalid length: %d", i, j, chunkLength)
}
if len(variant.delays) > 0 {
if err := validatePaddingDelayRange(variant.delays[j]); err != nil {
return 0, 0, fmt.Errorf("variant %d chunk %d has an invalid delay: %w", i, j, err)
}
}
}
length := paddingVariantLength(variant)
if length > maxPaddingTurnLength {
return 0, 0, fmt.Errorf("variant %d is too long: %d", i, length)
}
minLength = min(minLength, length)
maxLength = max(maxLength, length)
}
for _, index := range turn.sendVariants {
if index < 0 || index >= len(turn.variants) {
return 0, 0, fmt.Errorf("invalid send variant index: %d", index)
}
}
return minLength, maxLength, nil
}
func paddingTurnAcceptsLength(turn paddingTurn, length int) bool {
if len(turn.variants) == 0 {
return length >= turn.minLength && length <= turn.maxLength
}
for _, variant := range turn.variants {
if paddingVariantLength(variant) == length {
return true
}
}
return false
}
func paddingVariantLength(variant paddingVariant) int {
total := 0
for _, chunkLength := range variant.chunks {
total += chunkLength
}
return total
}
func validatePaddingDelayRange(delay paddingDelayRange) error {
if delay.min < 0 || delay.max < delay.min {
return fmt.Errorf("invalid range: %s-%s", delay.min, delay.max)
}
return nil
}
func randomPaddingDelay(delay paddingDelayRange) (time.Duration, error) {
if err := validatePaddingDelayRange(delay); err != nil {
return 0, err
}
if delay.min == delay.max {
return delay.min, nil
}
span := int64(delay.max-delay.min) + 1
offset, err := rand.Int(rand.Reader, big.NewInt(span))
if err != nil {
return 0, fmt.Errorf("select padding delay: %w", err)
}
return delay.min + time.Duration(offset.Int64()), nil
}
func randomPaddingIndex(length int) (int, error) {
if length < 1 {
return 0, fmt.Errorf("select from empty padding choices")
}
if length == 1 {
return 0, nil
}
index, err := rand.Int(rand.Reader, big.NewInt(int64(length)))
if err != nil {
return 0, fmt.Errorf("select padding choice: %w", err)
}
return int(index.Int64()), nil
}
func randomPaddingTarget(minLength, maxLength int) (int, error) {
if minLength == maxLength {
return minLength, nil
}
span := int64(maxLength-minLength) + 1
offset, err := rand.Int(rand.Reader, big.NewInt(span))
if err != nil {
return 0, fmt.Errorf("select padding length: %w", err)
}
return minLength + int(offset.Int64()), nil
}
@@ -0,0 +1,282 @@
package xmc
import (
"time"
)
// Length and write-boundary templates come from controlled Minecraft 26.1.2
// logins. Timing deliberately uses broad random bands that preserve only the
// rough ordering of short and long phases; it does not replay captured delays.
var startupPaddingSchedule2612 = []paddingTurn{
{
direction: paddingClientToServer,
variants: []paddingVariant{
paddingVariantFromChunks(2, 26, 16),
},
},
{
direction: paddingServerToClient,
variants: []paddingVariant{
paddingVariantFromChunks(26, 21, 25),
},
startDelay: millisecondRange(0, 20),
},
{
direction: paddingClientToServer,
variants: []paddingVariant{
paddingVariantFromChunks(25),
},
startDelay: millisecondRange(2, 22),
},
{
direction: paddingServerToClient,
variants: []paddingVariant{
registryPaddingVariant(),
},
startDelay: millisecondRange(20, 50),
},
{
direction: paddingClientToServer,
variants: []paddingVariant{
paddingVariantFromChunks(2),
},
startDelay: millisecondRange(10, 35),
},
{
direction: paddingServerToClient,
variants: []paddingVariant{
playStartPaddingVariant(4941, 252, 259, 267, 268, 251, 303, 259, 264, 54, 346),
playStartPaddingVariant(4941, 262, 284, 272, 260, 260, 313, 264, 151, 224, 207, 215, 224, 390),
playStartPaddingVariant(4941, 257, 272, 275, 260, 260, 313, 283, 274, 226, 207, 230, 215, 204, 221, 352),
playStartPaddingVariant(4941, 259, 272, 288, 260, 260, 311, 270, 70, 236, 223, 201, 210, 352),
playStartPaddingVariant(4941, 255, 269, 277, 263, 260, 136, 207, 210, 232, 325),
playStartPaddingVariant(4941, 259, 270, 274, 263, 258, 327, 170, 210, 375),
playStartPaddingVariant(4941, 257, 275, 291, 260, 260, 325, 269, 70, 230, 226, 207, 221, 352),
playStartPaddingVariant(4941, 252, 273, 262, 252, 254, 306, 93),
playStartPaddingVariant(4941, 273, 270, 269, 258, 256, 322, 221, 207, 215, 438),
playStartPaddingVariant(4941, 259, 275, 274, 250, 258, 308, 267, 154, 233, 209, 207, 213, 393),
playStartPaddingVariant(4941, 254, 267, 272, 260, 253, 311, 167, 204, 232, 207, 481, 8),
playStartPaddingVariant(4941, 259, 269, 272, 261, 313, 207, 213, 500, 19),
playStartPaddingVariant(4941, 262, 269, 274, 263, 274, 311, 270, 242, 210, 229, 221, 210, 431),
playStartPaddingVariant(4941, 259, 265, 277, 263, 277, 316, 269, 156, 204, 210, 226, 207, 413),
playStartPaddingVariant(4941, 215, 251, 249, 317, 260, 270, 249, 52),
playStartPaddingVariant(4941, 224, 263, 277, 316, 267, 272, 260, 138, 230, 226, 207, 204, 352),
playStartPaddingVariant(4941, 221, 258, 263, 319, 269, 288, 263, 136, 204, 210, 220, 207, 378),
playStartPaddingVariant(4941, 221, 258, 260, 316, 273, 291, 226, 204, 229, 213, 489, 8),
playStartPaddingVariant(4941, 238, 260, 261, 306, 272, 277, 260, 224, 241, 212, 207, 204, 393),
playStartPaddingVariant(4941, 224, 260, 260, 309, 272, 277, 277, 138, 207, 207, 212, 241, 352),
},
startDelay: millisecondRange(35, 50),
},
}
// These turns cover the finite Play-state tail through the client's
// player_loaded packet. Bounds are the observed per-turn minima and maxima
// across 20 controlled 26.1.2 logins; payload bytes remain opaque padding.
var playJoinPaddingSchedule2612 = []paddingTurn{
clientPlayPaddingTurn(6, 883),
serverPlayPaddingTurn(346, 58638),
clientPlayPaddingTurn(6, 887),
serverPlayPaddingTurn(388, 61077),
clientPlayPaddingTurn(2, 50),
serverPlayPaddingTurn(575, 65584),
clientPlayPaddingTurn(6, 45),
serverPlayPaddingTurn(86, 63563),
clientPlayPaddingTurn(2, 44),
serverPlayPaddingTurn(42, 51983),
clientPlayPaddingTurn(2, 851),
serverPlayPaddingTurn(309, 25083),
clientPlayPaddingTurn(2, 19),
serverPlayPaddingTurn(74, 63885),
clientPlayPaddingTurn(8, 24),
serverPlayPaddingTurn(30, 66128),
clientPlayPaddingTurn(2, 19),
serverPlayPaddingTurn(26, 35818),
clientPlayPaddingTurn(6, 19),
serverPlayPaddingTurn(35, 59407),
clientPlayPaddingTurn(6, 19),
serverPlayPaddingTurn(37, 65328),
clientPlayPaddingTurn(2, 19),
serverPlayPaddingTurn(26, 60622),
clientPlayPaddingTurn(6, 19),
serverPlayPaddingTurn(11, 60808),
clientPlayPaddingTurn(8, 43),
serverPlayPaddingTurn(55, 62027),
clientPlayPaddingTurn(2, 19),
serverPlayPaddingTurn(427, 65622),
clientPlayPaddingTurn(5, 19),
serverPlayPaddingTurn(35, 59401),
clientPlayPaddingTurn(6, 19),
}
type paddingLengthRange2612 struct {
minimum int
maximum int
}
type serverPlayLengthBranches2612 struct {
small paddingLengthRange2612
large paddingLengthRange2612
}
var serverPlayBranches2612 = []serverPlayLengthBranches2612{
{small: paddingLengthRange2612{346, 18812}, large: paddingLengthRange2612{51702, 58638}},
{small: paddingLengthRange2612{388, 20689}, large: paddingLengthRange2612{51445, 61077}},
{small: paddingLengthRange2612{575, 20915}, large: paddingLengthRange2612{41428, 65584}},
{small: paddingLengthRange2612{86, 2772}, large: paddingLengthRange2612{41428, 63563}},
{small: paddingLengthRange2612{42, 26813}, large: paddingLengthRange2612{51983, 51983}},
{small: paddingLengthRange2612{309, 19484}, large: paddingLengthRange2612{24837, 25083}},
{small: paddingLengthRange2612{74, 40686}, large: paddingLengthRange2612{63885, 63885}},
{small: paddingLengthRange2612{30, 44114}, large: paddingLengthRange2612{66128, 66128}},
{small: paddingLengthRange2612{26, 1464}, large: paddingLengthRange2612{9941, 35818}},
{small: paddingLengthRange2612{35, 42885}, large: paddingLengthRange2612{52194, 59407}},
{small: paddingLengthRange2612{37, 47553}, large: paddingLengthRange2612{61765, 65328}},
{small: paddingLengthRange2612{26, 1121}, large: paddingLengthRange2612{16162, 60622}},
{small: paddingLengthRange2612{11, 45629}, large: paddingLengthRange2612{60808, 60808}},
{small: paddingLengthRange2612{55, 10035}, large: paddingLengthRange2612{30237, 62027}},
{small: paddingLengthRange2612{427, 52536}, large: paddingLengthRange2612{64014, 65622}},
{small: paddingLengthRange2612{35, 22708}, large: paddingLengthRange2612{38987, 59401}},
}
// Each mask preserves only the small/large branch order from one baseline
// login. Actual lengths and timing are selected randomly inside each branch.
var serverPlayBranchMasks2612 = []uint32{
0x011c, 0x090a, 0x0821, 0xe921, 0x2102,
0x0844, 0xa101, 0x1106, 0x2e00, 0xab01,
0xe900, 0xac01, 0xab01, 0x8b80, 0x0808,
0x2001, 0x0901, 0x000a, 0x2c01, 0x0801,
}
type clientPlayBurst2612 struct {
playIndex int
regular paddingLengthRange2612
burst paddingLengthRange2612
}
var clientPlayBursts2612 = []clientPlayBurst2612{
{playIndex: 0, regular: paddingLengthRange2612{6, 44}, burst: paddingLengthRange2612{877, 883}},
{playIndex: 2, regular: paddingLengthRange2612{6, 45}, burst: paddingLengthRange2612{884, 887}},
{playIndex: 10, regular: paddingLengthRange2612{2, 19}, burst: paddingLengthRange2612{851, 851}},
}
// The 20 samples placed the one client initialization burst in these slots.
var clientPlayBurstChoices2612 = []int{
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1, 1,
2,
}
var paddingSchedule2612 = buildPaddingSchedule2612()
func buildPaddingSchedule2612() []paddingTurn {
schedule := make([]paddingTurn, 0, len(startupPaddingSchedule2612)+len(playJoinPaddingSchedule2612))
schedule = append(schedule, startupPaddingSchedule2612...)
schedule = append(schedule, playJoinPaddingSchedule2612...)
return schedule
}
func clientPlayPaddingTurn(minimum, maximum int) paddingTurn {
return paddingTurn{
direction: paddingClientToServer,
minLength: minimum,
maxLength: maximum,
startDelay: millisecondRange(1, 30),
writeChunkLength: 1024,
}
}
func serverPlayPaddingTurn(minimum, maximum int) paddingTurn {
return paddingTurn{
direction: paddingServerToClient,
minLength: minimum,
maxLength: maximum,
startDelay: millisecondRange(1, 45),
chunkDelay: millisecondRange(1, 4),
writeChunkMinLength: 32 * 1024,
writeChunkLength: maxPaddingChunkLength,
}
}
func newClientPaddingSchedule2612() ([]paddingTurn, error) {
choice, err := randomPaddingIndex(len(clientPlayBurstChoices2612))
if err != nil {
return nil, err
}
selectedBurst := clientPlayBurstChoices2612[choice]
schedule := append([]paddingTurn(nil), paddingSchedule2612...)
for i, burst := range clientPlayBursts2612 {
lengthRange := burst.regular
if i == selectedBurst {
lengthRange = burst.burst
}
turn := &schedule[len(startupPaddingSchedule2612)+burst.playIndex]
turn.sendMinLength = lengthRange.minimum
turn.sendMaxLength = lengthRange.maximum
}
return schedule, nil
}
type paddingPause struct {
chunk int
delay paddingDelayRange
}
func paddingVariantFromChunks(chunks ...int) paddingVariant {
return paddingVariant{chunks: chunks}
}
func pacedPaddingVariant(chunks []int, pauses ...paddingPause) paddingVariant {
delays := make([]paddingDelayRange, len(chunks))
for _, pause := range pauses {
if pause.chunk < 0 || pause.chunk >= len(delays) {
panic("xmc: padding pause index is outside its chunk template")
}
delays[pause.chunk] = pause.delay
}
return paddingVariant{chunks: chunks, delays: delays}
}
func registryPaddingVariant() paddingVariant {
return pacedPaddingVariant(
[]int{1590, 226, 329, 229, 186, 151, 78, 81, 79, 235, 67, 67, 78, 71, 82, 74, 982, 117, 1118, 1038, 970, 400, 239, 49, 50, 95, 65, 104, 32320, 2},
paddingPause{28, millisecondRange(1, 4)},
paddingPause{29, millisecondRange(44, 61)},
)
}
func playStartPaddingVariant(chunks ...int) paddingVariant {
if len(chunks) < 2 {
panic("xmc: play start padding variant needs at least two chunks")
}
return pacedPaddingVariant(
chunks,
paddingPause{len(chunks) / 2, millisecondRange(1, 5)},
paddingPause{len(chunks) - 1, millisecondRange(9, 20)},
)
}
func millisecondRange(minimum, maximum int) paddingDelayRange {
return paddingDelayRange{
min: time.Duration(minimum) * time.Millisecond,
max: time.Duration(maximum) * time.Millisecond,
}
}
func newServerPaddingSchedule2612() ([]paddingTurn, error) {
schedule := append([]paddingTurn(nil), paddingSchedule2612...)
profileIndex, err := randomPaddingIndex(len(serverPlayBranchMasks2612))
if err != nil {
return nil, err
}
profile := serverPlayBranchMasks2612[profileIndex]
for i, branches := range serverPlayBranches2612 {
lengthRange := branches.small
if profile&(1<<i) != 0 {
lengthRange = branches.large
}
turn := &schedule[len(startupPaddingSchedule2612)+1+i*2]
turn.sendMinLength = lengthRange.minimum
turn.sendMaxLength = lengthRange.maximum
}
return schedule, nil
}
@@ -0,0 +1,618 @@
package xmc
import (
"bytes"
"errors"
"io"
"net"
"strconv"
"strings"
"testing"
"time"
)
func TestPaddingTurnReachesFinalTargetLength(t *testing.T) {
turn := paddingTurn{direction: paddingClientToServer, minLength: 128, maxLength: 128}
const prefixLength = 3
var encoded bytes.Buffer
if err := writePaddingTurn(&encoded, turn, prefixLength); err != nil {
t.Fatal(err)
}
if got := prefixLength + encoded.Len(); got != turn.minLength {
t.Fatalf("total turn length = %d, want %d", got, turn.minLength)
}
encodedReader := bytes.NewReader(encoded.Bytes())
var recordLength Varint
if err := recordLength.readFrom(encodedReader); err != nil {
t.Fatal(err)
}
if got := int(recordLength); got != encoded.Len() {
t.Fatalf("record length = %d, encoded = %d", got, encoded.Len())
}
if err := readPaddingTurn(bytes.NewReader(encoded.Bytes()), turn, prefixLength); err != nil {
t.Fatal(err)
}
}
func TestPaddingTurnSupportsThreeByteTarget(t *testing.T) {
turn := paddingTurn{direction: paddingClientToServer, minLength: 3, maxLength: 3}
var encoded bytes.Buffer
if err := writePaddingTurn(&encoded, turn, 0); err != nil {
t.Fatal(err)
}
if got := encoded.Len(); got != 3 {
t.Fatalf("padding length = %d, want 3", got)
}
if err := readPaddingTurn(bytes.NewReader(encoded.Bytes()), turn, 0); err != nil {
t.Fatal(err)
}
}
func TestPaddingTurnVarintBoundaries(t *testing.T) {
for _, targetLength := range []int{127, 128, 16383, 16384} {
t.Run(strconv.Itoa(targetLength), func(t *testing.T) {
turn := paddingTurn{direction: paddingClientToServer, minLength: targetLength, maxLength: targetLength}
var encoded bytes.Buffer
if err := writePaddingTurn(&encoded, turn, 0); err != nil {
t.Fatal(err)
}
if encoded.Len() != targetLength {
t.Fatalf("padding length = %d, want %d", encoded.Len(), targetLength)
}
if err := readPaddingTurn(bytes.NewReader(encoded.Bytes()), turn, 0); err != nil {
t.Fatal(err)
}
})
}
}
func TestPaddingTurnRandomRange(t *testing.T) {
turn := paddingTurn{direction: paddingServerToClient, minLength: 127, maxLength: 129}
seen := make(map[int]bool)
for range 100 {
var encoded bytes.Buffer
if err := writePaddingTurn(&encoded, turn, 0); err != nil {
t.Fatal(err)
}
if encoded.Len() < turn.minLength || encoded.Len() > turn.maxLength {
t.Fatalf("padding length = %d", encoded.Len())
}
seen[encoded.Len()] = true
}
if len(seen) < 2 {
t.Fatalf("padding range did not vary: %v", seen)
}
}
func TestPaddingTurnUsesRestrictedSendRange(t *testing.T) {
turn := paddingTurn{
direction: paddingServerToClient,
minLength: 3,
maxLength: 100,
sendMinLength: 90,
sendMaxLength: 100,
}
seen := make(map[int]bool)
for range 100 {
var encoded bytes.Buffer
if err := writePaddingTurn(&encoded, turn, 0); err != nil {
t.Fatal(err)
}
if encoded.Len() < turn.sendMinLength || encoded.Len() > turn.sendMaxLength {
t.Fatalf("padding length = %d", encoded.Len())
}
seen[encoded.Len()] = true
if err := readPaddingTurn(bytes.NewReader(encoded.Bytes()), turn, 0); err != nil {
t.Fatal(err)
}
}
if len(seen) < 2 {
t.Fatalf("restricted send range did not vary: %v", seen)
}
}
func TestPaddingScheduleSynchronizesDirections(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
schedule := []paddingTurn{
{direction: paddingClientToServer, minLength: 33, maxLength: 33},
{direction: paddingServerToClient, minLength: 4097, maxLength: 4097},
{direction: paddingClientToServer, minLength: 16385, maxLength: 16385},
}
serverDone := make(chan error, 1)
go func() {
serverDone <- runPaddingSchedule(server, server, false, 3, schedule)
}()
if err := runPaddingSchedule(client, client, true, 3, schedule); err != nil {
t.Fatal(err)
}
select {
case err := <-serverDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("server padding schedule did not complete")
}
}
func TestReadPaddingTurnHandlesFragmentedInput(t *testing.T) {
turn := paddingTurn{direction: paddingClientToServer, minLength: 1024, maxLength: 1024}
var encoded bytes.Buffer
if err := writePaddingTurn(&encoded, turn, 0); err != nil {
t.Fatal(err)
}
if err := readPaddingTurn(&oneByteReader{reader: bytes.NewReader(encoded.Bytes())}, turn, 0); err != nil {
t.Fatal(err)
}
}
func TestReadPaddingTurnRejectsInvalidLength(t *testing.T) {
turn := paddingTurn{direction: paddingClientToServer, minLength: 64, maxLength: 96}
data := encodePaddingLength(t, 63)
if err := readPaddingTurn(bytes.NewReader(data), turn, 0); err == nil || !strings.Contains(err.Error(), "outside") {
t.Fatalf("error = %v", err)
}
}
func TestReadPaddingTurnRejectsNonCanonicalHeader(t *testing.T) {
turn := paddingTurn{direction: paddingClientToServer, minLength: 3, maxLength: 3}
err := readPaddingTurn(bytes.NewReader([]byte{0x83, 0x00, 0x00}), turn, 0)
if err == nil || !strings.Contains(err.Error(), "non-canonical") {
t.Fatalf("error = %v", err)
}
}
func TestReadPaddingTurnRejectsTruncatedBody(t *testing.T) {
turn := paddingTurn{direction: paddingClientToServer, minLength: 64, maxLength: 64}
data := encodePaddingLength(t, 64)
if err := readPaddingTurn(bytes.NewReader(data), turn, 0); err == nil || !strings.Contains(err.Error(), "body") {
t.Fatalf("error = %v", err)
}
}
func TestReadPaddingTurnHonorsConnectionTimeout(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
if err := server.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil {
t.Fatal(err)
}
turn := paddingTurn{direction: paddingClientToServer, minLength: 64, maxLength: 64}
err := readPaddingTurn(server, turn, 0)
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("error = %v, want network timeout", err)
}
}
func TestValidatePaddingSchedule(t *testing.T) {
tests := []struct {
name string
schedule []paddingTurn
prefix int
}{
{name: "empty"},
{name: "bad direction", schedule: []paddingTurn{{direction: 99, minLength: 4, maxLength: 4}}},
{name: "too small", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 0, maxLength: 4}}},
{name: "reversed range", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 7}}},
{name: "wrong first direction", prefix: 3, schedule: []paddingTurn{{direction: paddingServerToClient, minLength: 8, maxLength: 8}}},
{name: "prefix leaves no header", prefix: 8, schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8}}},
{name: "same direction", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8}, {direction: paddingClientToServer, minLength: 8, maxLength: 8}}},
{name: "range with variants", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8, variants: []paddingVariant{paddingVariantFromChunks(8)}}}},
{name: "empty variant", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{{}}}}},
{name: "bad chunk", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{paddingVariantFromChunks(maxPaddingChunkLength + 1)}}}},
{
name: "delay mismatch",
schedule: []paddingTurn{{
direction: paddingClientToServer,
variants: []paddingVariant{{
chunks: []int{4, 4},
delays: []paddingDelayRange{{min: time.Millisecond, max: time.Millisecond}},
}},
}},
},
{name: "reversed start delay", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8, startDelay: paddingDelayRange{min: 2 * time.Millisecond, max: time.Millisecond}}}},
{name: "reversed generated chunk delay", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8, chunkDelay: paddingDelayRange{min: 2 * time.Millisecond, max: time.Millisecond}}}},
{name: "oversized generated chunk", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8, writeChunkLength: maxPaddingChunkLength + 1}}},
{name: "reversed generated chunk range", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8, writeChunkMinLength: 9, writeChunkLength: 8}}},
{name: "variant with generated chunks", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{paddingVariantFromChunks(8)}, writeChunkLength: 8}}},
{name: "send range outside accepted range", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 16, sendMinLength: 7, sendMaxLength: 12}}},
{name: "variant with send range", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{paddingVariantFromChunks(8)}, sendMinLength: 8, sendMaxLength: 8}}},
{name: "negative chunk delay", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{{chunks: []int{8}, delays: []paddingDelayRange{{min: -time.Millisecond}}}}}}},
{name: "bad send variant", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{paddingVariantFromChunks(8)}, sendVariants: []int{1}}}},
{name: "prefix splits chunk", prefix: 3, schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{paddingVariantFromChunks(8, 4)}}}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := validatePaddingSchedule(test.schedule, test.prefix); err == nil {
t.Fatal("expected invalid padding schedule")
}
})
}
}
func TestPaddingSchedule2612MatchesCapturedTemplates(t *testing.T) {
wantDirections := []paddingDirection{
paddingClientToServer,
paddingServerToClient,
paddingClientToServer,
paddingServerToClient,
paddingClientToServer,
paddingServerToClient,
}
wantLengths := [][]int{
{44},
{72},
{25},
{41172},
{2},
{7464, 8267, 8790, 8153, 7375, 7347, 8184, 6633, 7670, 8241, 7857, 7254, 8407, 8283, 6804, 8177, 8177, 7929, 8296, 8177},
}
if len(paddingSchedule2612) != len(wantDirections)+33 {
t.Fatalf("padding schedule has %d turns, want %d", len(paddingSchedule2612), len(wantDirections)+33)
}
for i, turn := range paddingSchedule2612[:len(wantDirections)] {
if turn.direction != wantDirections[i] {
t.Fatalf("padding turn %d direction = %d, want %d", i, turn.direction, wantDirections[i])
}
if len(turn.variants) != len(wantLengths[i]) {
t.Fatalf("padding turn %d has %d variants, want %d", i, len(turn.variants), len(wantLengths[i]))
}
for j, variant := range turn.variants {
if got := paddingVariantLength(variant); got != wantLengths[i][j] {
t.Fatalf("padding turn %d variant %d length = %d, want %d", i, j, got, wantLengths[i][j])
}
}
}
wantPlayBounds := [][2]int{
{6, 883},
{346, 58638},
{6, 887},
{388, 61077},
{2, 50},
{575, 65584},
{6, 45},
{86, 63563},
{2, 44},
{42, 51983},
{2, 851},
{309, 25083},
{2, 19},
{74, 63885},
{8, 24},
{30, 66128},
{2, 19},
{26, 35818},
{6, 19},
{35, 59407},
{6, 19},
{37, 65328},
{2, 19},
{26, 60622},
{6, 19},
{11, 60808},
{8, 43},
{55, 62027},
{2, 19},
{427, 65622},
{5, 19},
{35, 59401},
{6, 19},
}
for i, want := range wantPlayBounds {
turn := paddingSchedule2612[len(wantDirections)+i]
wantDirection := paddingClientToServer
if i%2 == 1 {
wantDirection = paddingServerToClient
}
if turn.direction != wantDirection {
t.Fatalf("play turn %d direction = %d, want %d", i, turn.direction, wantDirection)
}
if turn.minLength != want[0] || turn.maxLength != want[1] {
t.Fatalf("play turn %d bounds = %d-%d, want %d-%d", i, turn.minLength, turn.maxLength, want[0], want[1])
}
if len(turn.variants) != 0 {
t.Fatalf("play turn %d unexpectedly has captured variants", i)
}
}
if got := len(paddingSchedule2612[3].variants[0].chunks); got != 30 {
t.Fatalf("registry turn chunks = %d, want 30", got)
}
minimumPlayStart := maxPaddingTurnLength
maximumPlayStart := 0
for _, variant := range paddingSchedule2612[5].variants {
length := paddingVariantLength(variant)
minimumPlayStart = min(minimumPlayStart, length)
maximumPlayStart = max(maximumPlayStart, length)
if variant.chunks[0] != 4941 {
t.Fatalf("play start first chunk = %d, want 4941", variant.chunks[0])
}
}
if minimumPlayStart != 6633 || maximumPlayStart != 8790 {
t.Fatalf("play start bounds = %d-%d, want 6633-8790", minimumPlayStart, maximumPlayStart)
}
if err := validatePaddingSchedule(paddingSchedule2612, 2); err != nil {
t.Fatalf("captured schedule is invalid: %v", err)
}
serverSchedule, err := newServerPaddingSchedule2612()
if err != nil {
t.Fatal(err)
}
if err = validatePaddingSchedule(serverSchedule, 2); err != nil {
t.Fatalf("server schedule is invalid: %v", err)
}
for i, branches := range serverPlayBranches2612 {
turn := serverSchedule[len(startupPaddingSchedule2612)+1+i*2]
got := paddingLengthRange2612{turn.sendMinLength, turn.sendMaxLength}
if got != branches.small && got != branches.large {
t.Fatalf("server play turn %d send range = %v, want %v or %v", i, got, branches.small, branches.large)
}
}
for range 20 {
clientSchedule, clientErr := newClientPaddingSchedule2612()
if clientErr != nil {
t.Fatal(clientErr)
}
if clientErr = validatePaddingSchedule(clientSchedule, 2); clientErr != nil {
t.Fatalf("client schedule is invalid: %v", clientErr)
}
burstCount := 0
for _, burst := range clientPlayBursts2612 {
turn := clientSchedule[len(startupPaddingSchedule2612)+burst.playIndex]
got := paddingLengthRange2612{turn.sendMinLength, turn.sendMaxLength}
switch got {
case burst.regular:
case burst.burst:
burstCount++
default:
t.Fatalf("client play turn %d send range = %v", burst.playIndex, got)
}
}
if burstCount != 1 {
t.Fatalf("client schedule has %d initialization bursts, want 1", burstCount)
}
}
for variantIndex := range paddingSchedule2612[3].variants {
turn := paddingSchedule2612[3]
turn.sendVariants = []int{variantIndex}
var encoded bytes.Buffer
if err = writePaddingTurnWithSleep(&encoded, turn, 0, func(time.Duration) {}); err != nil {
t.Fatal(err)
}
if err = readPaddingTurn(bytes.NewReader(encoded.Bytes()), paddingSchedule2612[3], 0); err != nil {
t.Fatalf("registry variant %d was rejected: %v", variantIndex, err)
}
}
}
func TestPaddingVariantPreservesWriteBoundaries(t *testing.T) {
tests := []struct {
name string
turn paddingTurn
prefix int
want []int
}{
{name: "login acknowledged turn", turn: paddingSchedule2612[0], prefix: 2, want: []int{26, 16}},
{name: "server response turn", turn: paddingSchedule2612[1], want: []int{26, 21, 25}},
{name: "single packet turn", turn: paddingSchedule2612[2], want: []int{25}},
{name: "fixed registry profile", turn: paddingSchedule2612[3], want: paddingSchedule2612[3].variants[0].chunks},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var writer recordingWriter
if err := writePaddingTurnWithSleep(&writer, test.turn, test.prefix, func(time.Duration) {}); err != nil {
t.Fatal(err)
}
if len(writer.writes) != len(test.want) {
t.Fatalf("writes = %v, want %v", writer.writes, test.want)
}
for i := range test.want {
if writer.writes[i] != test.want[i] {
t.Fatalf("writes = %v, want %v", writer.writes, test.want)
}
}
if err := readPaddingTurn(bytes.NewReader(writer.Bytes()), test.turn, test.prefix); err != nil {
t.Fatal(err)
}
})
}
}
func TestPaddingVariantAppliesPacing(t *testing.T) {
turn := paddingTurn{
direction: paddingClientToServer,
startDelay: paddingDelayRange{min: 3 * time.Millisecond, max: 3 * time.Millisecond},
variants: []paddingVariant{{
chunks: []int{3, 5, 7},
delays: []paddingDelayRange{
{},
{min: 2 * time.Millisecond, max: 2 * time.Millisecond},
{min: 4 * time.Millisecond, max: 4 * time.Millisecond},
},
}},
}
var slept []time.Duration
var writer recordingWriter
if err := writePaddingTurnWithSleep(&writer, turn, 3, func(delay time.Duration) {
slept = append(slept, delay)
}); err != nil {
t.Fatal(err)
}
want := []time.Duration{3 * time.Millisecond, 2 * time.Millisecond, 4 * time.Millisecond}
if len(slept) != len(want) {
t.Fatalf("delays = %v, want %v", slept, want)
}
for i := range want {
if slept[i] != want[i] {
t.Fatalf("delays = %v, want %v", slept, want)
}
}
}
func TestGeneratedPaddingChunksApplyPacing(t *testing.T) {
turn := paddingTurn{
direction: paddingServerToClient,
minLength: 100,
maxLength: 100,
writeChunkLength: 32,
chunkDelay: paddingDelayRange{min: 2 * time.Millisecond, max: 2 * time.Millisecond},
}
var slept []time.Duration
var writer recordingWriter
if err := writePaddingTurnWithSleep(&writer, turn, 0, func(delay time.Duration) {
slept = append(slept, delay)
}); err != nil {
t.Fatal(err)
}
wantWrites := []int{32, 32, 32, 4}
if !slicesEqual(writer.writes, wantWrites) {
t.Fatalf("writes = %v, want %v", writer.writes, wantWrites)
}
wantSleeps := []time.Duration{2 * time.Millisecond, 2 * time.Millisecond, 2 * time.Millisecond}
if !slicesEqual(slept, wantSleeps) {
t.Fatalf("delays = %v, want %v", slept, wantSleeps)
}
if err := readPaddingTurn(bytes.NewReader(writer.Bytes()), turn, 0); err != nil {
t.Fatal(err)
}
}
func TestGeneratedPaddingChunkLengthIsRandomized(t *testing.T) {
turn := paddingTurn{
direction: paddingServerToClient,
minLength: 100,
maxLength: 100,
writeChunkMinLength: 16,
writeChunkLength: 32,
}
seen := make(map[int]bool)
for range 100 {
var writer recordingWriter
if err := writePaddingTurnWithSleep(&writer, turn, 0, func(time.Duration) {}); err != nil {
t.Fatal(err)
}
firstWrite := writer.writes[0]
if firstWrite < turn.writeChunkMinLength || firstWrite > turn.writeChunkLength {
t.Fatalf("first write = %d", firstWrite)
}
seen[firstWrite] = true
}
if len(seen) < 2 {
t.Fatalf("generated write chunk length did not vary: %v", seen)
}
}
func TestPaddingDelayRangeIsRandomized(t *testing.T) {
delayRange := millisecondRange(25, 40)
seen := make(map[time.Duration]bool)
for range 100 {
delay, err := randomPaddingDelay(delayRange)
if err != nil {
t.Fatal(err)
}
if delay < delayRange.min || delay > delayRange.max {
t.Fatalf("delay = %s, want %s-%s", delay, delayRange.min, delayRange.max)
}
seen[delay] = true
}
if len(seen) < 2 {
t.Fatalf("padding delay did not vary: %v", seen)
}
}
func TestPaddingSchedule2612UsesCoarseTimingBands(t *testing.T) {
assertDelayRange(t, "turn 3 to 4", paddingSchedule2612[3].startDelay, 20*time.Millisecond, 50*time.Millisecond)
assertDelayRange(t, "turn 5 to 6", paddingSchedule2612[5].startDelay, 35*time.Millisecond, 50*time.Millisecond)
assertDelayRange(t, "first play client turn", paddingSchedule2612[6].startDelay, time.Millisecond, 30*time.Millisecond)
assertDelayRange(t, "first play server turn", paddingSchedule2612[7].startDelay, time.Millisecond, 45*time.Millisecond)
assertDelayRange(t, "play server chunk pacing", paddingSchedule2612[7].chunkDelay, time.Millisecond, 4*time.Millisecond)
if paddingSchedule2612[6].writeChunkLength != 1024 {
t.Fatalf("play client write chunk = %d, want 1024", paddingSchedule2612[6].writeChunkLength)
}
if paddingSchedule2612[7].writeChunkLength != maxPaddingChunkLength {
t.Fatalf("play server write chunk = %d, want %d", paddingSchedule2612[7].writeChunkLength, maxPaddingChunkLength)
}
if paddingSchedule2612[7].writeChunkMinLength != 32*1024 {
t.Fatalf("play server minimum write chunk = %d, want %d", paddingSchedule2612[7].writeChunkMinLength, 32*1024)
}
for i, variant := range paddingSchedule2612[3].variants {
minimum, maximum := paddingVariantDelayBounds(variant)
if minimum != 45*time.Millisecond || maximum != 65*time.Millisecond {
t.Fatalf("turn 4 variant %d duration = %s-%s, want 45ms-65ms", i, minimum, maximum)
}
}
for i, variant := range paddingSchedule2612[5].variants {
minimum, maximum := paddingVariantDelayBounds(variant)
if minimum != 10*time.Millisecond || maximum != 25*time.Millisecond {
t.Fatalf("turn 6 variant %d duration = %s-%s, want 10ms-25ms", i, minimum, maximum)
}
}
}
func assertDelayRange(t *testing.T, name string, got paddingDelayRange, minimum, maximum time.Duration) {
t.Helper()
if got.min != minimum || got.max != maximum {
t.Fatalf("%s delay = %s-%s, want %s-%s", name, got.min, got.max, minimum, maximum)
}
}
func paddingVariantDelayBounds(variant paddingVariant) (time.Duration, time.Duration) {
var minimum time.Duration
var maximum time.Duration
for _, delay := range variant.delays {
minimum += delay.min
maximum += delay.max
}
return minimum, maximum
}
func slicesEqual[T comparable](left, right []T) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func encodePaddingLength(t *testing.T, length int) []byte {
t.Helper()
var encoded bytes.Buffer
value := Varint(length)
if err := value.writeTo(&encoded); err != nil {
t.Fatal(err)
}
return encoded.Bytes()
}
type oneByteReader struct {
reader io.Reader
}
func (r *oneByteReader) Read(p []byte) (int, error) {
if len(p) > 1 {
p = p[:1]
}
return r.reader.Read(p)
}
type recordingWriter struct {
bytes.Buffer
writes []int
}
func (w *recordingWriter) Write(p []byte) (int, error) {
w.writes = append(w.writes, len(p))
return w.Buffer.Write(p)
}
@@ -0,0 +1,69 @@
package xmc
import "fmt"
type loginProfile struct {
Username string
UUID UUID
TexturesValue string
TexturesSignature string
}
func profilesFromConfig(configured []*Profile) ([]loginProfile, error) {
if len(configured) == 0 {
return nil, fmt.Errorf("empty profiles")
}
profiles := make([]loginProfile, 0, len(configured))
for _, configuredProfile := range configured {
if configuredProfile == nil || configuredProfile.Username == "" {
return nil, fmt.Errorf("invalid profile")
}
if len(configuredProfile.Uuid) != len(UUID{}) {
return nil, fmt.Errorf("bad profile UUID length: %d", len(configuredProfile.Uuid))
}
if configuredProfile.TexturesValue == "" || configuredProfile.TexturesSignature == "" {
return nil, fmt.Errorf("incomplete profile textures")
}
profile := loginProfile{
Username: configuredProfile.Username,
TexturesValue: configuredProfile.TexturesValue,
TexturesSignature: configuredProfile.TexturesSignature,
}
copy(profile.UUID[:], configuredProfile.Uuid)
profiles = append(profiles, profile)
}
return profiles, nil
}
func findProfile(profiles []loginProfile, username string, uuid UUID) (loginProfile, bool) {
for _, profile := range profiles {
if profile.Username == username && profile.UUID == uuid {
return profile, true
}
}
return loginProfile{}, false
}
func readLoginSuccess(packet *mcPacket) (loginProfile, error) {
var (
profile loginProfile
username String
propertyCount Varint
propertyName String
value String
signed Boolean
signature String
)
if err := packet.readFields(&profile.UUID, &username, &propertyCount, &propertyName, &value, &signed, &signature); err != nil {
return loginProfile{}, err
}
if propertyCount != 1 || propertyName != "textures" || !signed {
return loginProfile{}, fmt.Errorf("invalid login profile properties")
}
profile.Username = string(username)
profile.TexturesValue = string(value)
profile.TexturesSignature = string(signature)
return profile, nil
}
@@ -0,0 +1,33 @@
package xmc
import (
"bytes"
"testing"
)
func TestProfilesFromConfigRejectsEmpty(t *testing.T) {
if _, err := profilesFromConfig(nil); err == nil {
t.Fatal("expected empty profiles error")
}
}
func TestProfilesFromConfig(t *testing.T) {
uuid := bytes.Repeat([]byte{0x2a}, 16)
profiles, err := profilesFromConfig([]*Profile{
{
Username: "SignedUser",
Uuid: uuid,
TexturesValue: "textures-value",
TexturesSignature: "textures-signature",
},
})
if err != nil {
t.Fatalf("build explicit profile: %v", err)
}
if len(profiles) != 1 || profiles[0].Username != "SignedUser" {
t.Fatalf("unexpected profile: %+v", profiles)
}
if profiles[0].TexturesValue != "textures-value" || profiles[0].TexturesSignature != "textures-signature" {
t.Fatalf("textures were not preserved: %+v", profiles[0])
}
}
+171 -62
View File
@@ -7,6 +7,11 @@ import (
"io"
)
const (
maxPacketDataLength = 32 * 1024
maxPacketBodyLength = maxPacketDataLength + 5
)
type field interface {
readFrom(r io.Reader) error
writeTo(w io.Writer) error
@@ -18,25 +23,38 @@ type mcPacket struct {
}
func readPacket(b io.Reader) (*mcPacket, error) {
var packetLength Varint
err := packetLength.readFrom(b)
packet, _, err := readPacketWithLength(b)
return packet, err
}
func readPacketWithLength(b io.Reader) (*mcPacket, int, error) {
packetData, wireLength, err := readFrame(b, maxPacketBodyLength)
if err != nil {
return nil, fmt.Errorf("read packet length: %w", err)
return nil, 0, err
}
packet, err := decodePacketBody(packetData)
return packet, wireLength, err
}
func decodePacketBody(packetData []byte) (*mcPacket, error) {
if len(packetData) < 1 || len(packetData) > maxPacketBodyLength {
return nil, fmt.Errorf("read packet: bad length: %d", len(packetData))
}
body := bytes.NewReader(packetData)
var packetID Varint
err = packetID.readFrom(b)
err := packetID.readFrom(body)
if err != nil {
return nil, fmt.Errorf("read packet ID: %w", err)
}
dataLength := int(packetLength) - varintSize(packetID)
if dataLength < 0 || dataLength > 1024*32 {
dataLength := body.Len()
if dataLength > maxPacketDataLength {
return nil, fmt.Errorf("read packet: bad length: %d", dataLength)
}
data := make([]byte, dataLength)
_, err = io.ReadFull(b, data)
_, err = io.ReadFull(body, data)
if err != nil {
return nil, fmt.Errorf("read packet data: %w", err)
}
@@ -47,9 +65,24 @@ func readPacket(b io.Reader) (*mcPacket, error) {
}, nil
}
func readFrame(r io.Reader, maxLength int) ([]byte, int, error) {
frameLength, prefixLength, err := readVarintWithLength(r)
if err != nil {
return nil, 0, fmt.Errorf("read packet length: %w", err)
}
if frameLength < 1 || int(frameLength) > maxLength {
return nil, 0, fmt.Errorf("read packet: bad length: %d", frameLength)
}
frame := make([]byte, int(frameLength))
if _, err := io.ReadFull(r, frame); err != nil {
return nil, 0, fmt.Errorf("read packet data: %w", err)
}
return frame, prefixLength + len(frame), nil
}
func (p *mcPacket) readFields(fields ...field) error {
r := bytes.NewReader(p.data)
for _, field := range fields {
err := field.readFrom(r)
if err != nil {
@@ -62,47 +95,49 @@ func (p *mcPacket) readFields(fields ...field) error {
type Varint int32
const (
SEGMENT_BITS = 0x7F
CONTINUE_BIT = 0x80
)
func (v *Varint) readFrom(r io.Reader) error {
SEGMENT_BITS := byte(0x7F)
CONTINUE_BIT := byte(0x80)
var err error
var value int32 = 0
var position int32 = 0
var currentByte byte
for true {
currentByte, err = readByte(r)
if err != nil {
return fmt.Errorf("read varint: %w", err)
}
value |= int32(currentByte&SEGMENT_BITS) << position
if (currentByte & CONTINUE_BIT) == 0 {
break
}
position += 7
if position >= 32 {
return fmt.Errorf("read varint: too large")
}
value, _, err := readVarintWithLength(r)
if err != nil {
return err
}
*v = Varint(value)
*v = value
return nil
}
func (v *Varint) writeTo(w io.Writer) error {
SEGMENT_BITS := byte(0x7F)
CONTINUE_BIT := byte(0x80)
func readVarintWithLength(r io.Reader) (Varint, int, error) {
var value int32
for index := 0; index < 5; index++ {
currentByte, err := readByte(r)
if err != nil {
return 0, 0, fmt.Errorf("read varint: %w", err)
}
if index == 4 && currentByte&0xf0 != 0 {
return 0, 0, fmt.Errorf("read varint: too large")
}
value |= int32(currentByte&SEGMENT_BITS) << (7 * index)
value := int32(*v)
if currentByte&CONTINUE_BIT == 0 {
parsed := Varint(value)
length := index + 1
if length != varintSize(parsed) {
return 0, 0, fmt.Errorf("read varint: non-canonical encoding")
}
return parsed, length, nil
}
}
return 0, 0, fmt.Errorf("read varint: too large")
}
func (v *Varint) writeTo(w io.Writer) error {
value := uint32(*v)
for {
currentByte := byte(value & int32(SEGMENT_BITS))
currentByte := byte(value & SEGMENT_BITS)
value >>= 7
if value != 0 {
currentByte |= CONTINUE_BIT
@@ -122,11 +157,12 @@ func (v *Varint) writeTo(w io.Writer) error {
}
func varintSize(value Varint) int {
uintValue := uint32(value)
size := 0
for {
for range 5 {
size++
value >>= 7
if value == 0 {
uintValue >>= 7
if uintValue == 0 {
break
}
}
@@ -238,6 +274,31 @@ func (v *UUID) readFrom(r io.Reader) error {
return nil
}
type Boolean bool
func (v *Boolean) readFrom(r io.Reader) error {
b, err := readByte(r)
if err != nil {
return fmt.Errorf("read boolean: %w", err)
}
if b > 1 {
return fmt.Errorf("read boolean: invalid value: %d", b)
}
*v = b == 1
return nil
}
func (v *Boolean) writeTo(w io.Writer) error {
value := byte(0)
if *v {
value = 1
}
if _, err := w.Write([]byte{value}); err != nil {
return fmt.Errorf("write boolean: %w", err)
}
return nil
}
func (v *UUID) writeTo(w io.Writer) error {
_, err := w.Write(v[:])
if err != nil {
@@ -256,7 +317,7 @@ func (v *Bytes) readFrom(r io.Reader) error {
}
if length < 0 || length >= 1024 {
return fmt.Errorf("read bytes: invalid size: %d", err)
return fmt.Errorf("read bytes: invalid size: %d", length)
}
buf := make([]byte, length)
@@ -271,6 +332,24 @@ func (v *Bytes) readFrom(r io.Reader) error {
return nil
}
type RestBytes []byte
func (v *RestBytes) readFrom(r io.Reader) error {
buf, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("read remaining bytes: %w", err)
}
*v = append((*v)[:0], buf...)
return nil
}
func (v *RestBytes) writeTo(w io.Writer) error {
if _, err := w.Write(*v); err != nil {
return fmt.Errorf("write remaining bytes: %w", err)
}
return nil
}
func (v *Bytes) writeTo(w io.Writer) error {
length := Varint(len(*v))
err := length.writeTo(w)
@@ -297,36 +376,66 @@ func readByte(r io.Reader) (byte, error) {
}
func writePacket(w io.Writer, packetID int, fields ...field) error {
_, err := writePacketWithLength(w, packetID, fields...)
return err
}
func writePacketWithLength(w io.Writer, packetID int, fields ...field) (int, error) {
frame, err := encodePacket(packetID, fields...)
if err != nil {
return 0, err
}
if err = writeFull(w, frame); err != nil {
return 0, fmt.Errorf("write packet data: %w", err)
}
return len(frame), nil
}
func encodePacket(packetID int, fields ...field) ([]byte, error) {
var dataBuf bytes.Buffer
for _, field := range fields {
err := field.writeTo(&dataBuf)
if err != nil {
return fmt.Errorf("write packet field: %w", err)
return nil, fmt.Errorf("write packet field: %w", err)
}
}
var buf bytes.Buffer
var packetLength Varint = Varint(varintSize(Varint(packetID)) + dataBuf.Len())
err := packetLength.writeTo(&buf)
if err != nil {
return fmt.Errorf("write packet length: %w", err)
if dataBuf.Len() > maxPacketDataLength {
return nil, fmt.Errorf("write packet: bad length: %d", dataBuf.Len())
}
var packetIDVarint Varint = Varint(packetID)
err = packetIDVarint.writeTo(&buf)
if err != nil {
return fmt.Errorf("write packet ID: %w", err)
packetIDVarint := Varint(packetID)
bodyLength := varintSize(packetIDVarint) + dataBuf.Len()
if bodyLength > maxPacketBodyLength {
return nil, fmt.Errorf("write packet: bad length: %d", bodyLength)
}
buf.Write(dataBuf.Bytes())
_, err = w.Write(buf.Bytes())
if err != nil {
return fmt.Errorf("write packet data: %w", err)
var frame bytes.Buffer
frame.Grow(varintSize(Varint(bodyLength)) + bodyLength)
frameLength := Varint(bodyLength)
if err := frameLength.writeTo(&frame); err != nil {
return nil, fmt.Errorf("write packet length: %w", err)
}
if err := packetIDVarint.writeTo(&frame); err != nil {
return nil, fmt.Errorf("write packet ID: %w", err)
}
frame.Write(dataBuf.Bytes())
return frame.Bytes(), nil
}
func writeFull(w io.Writer, p []byte) error {
for len(p) > 0 {
n, err := w.Write(p)
if n > 0 {
p = p[n:]
}
if err != nil {
return err
}
if n == 0 {
return io.ErrShortWrite
}
}
return nil
}
@@ -0,0 +1,56 @@
package xmc
import (
"bytes"
"strings"
"testing"
)
func TestReadPacketDoesNotConsumeFollowingPacket(t *testing.T) {
data := []byte{0x01, 0x80, 0x01, 0x00}
r := bytes.NewReader(data)
if _, err := readPacket(r); err == nil {
t.Fatal("expected truncated packet ID to fail")
}
pkt, err := readPacket(r)
if err != nil {
t.Fatalf("read following packet: %v", err)
}
if pkt.packetID != 0 {
t.Fatalf("packet ID = %d", pkt.packetID)
}
}
func TestPacketWithLengthReportsWireBytes(t *testing.T) {
var wire bytes.Buffer
written, err := writePacketWithLength(&wire, 0x03)
if err != nil {
t.Fatal(err)
}
if written != 2 || !bytes.Equal(wire.Bytes(), []byte{0x01, 0x03}) {
t.Fatalf("wire = %x, length = %d", wire.Bytes(), written)
}
packet, read, err := readPacketWithLength(bytes.NewReader(wire.Bytes()))
if err != nil {
t.Fatal(err)
}
if packet.packetID != 0x03 || read != written {
t.Fatalf("packet ID = %d, read = %d, written = %d", packet.packetID, read, written)
}
}
func TestReadPacketRejectsNonCanonicalLengthVarint(t *testing.T) {
_, _, err := readPacketWithLength(bytes.NewReader([]byte{0x81, 0x00, 0x03}))
if err == nil || !strings.Contains(err.Error(), "non-canonical") {
t.Fatalf("error = %v", err)
}
}
func TestVarintRejectsOversizedFifthByte(t *testing.T) {
var value Varint
err := value.readFrom(bytes.NewReader([]byte{0xff, 0xff, 0xff, 0xff, 0x1f}))
if err == nil || !strings.Contains(err.Error(), "too large") {
t.Fatalf("error = %v", err)
}
}
+100 -25
View File
@@ -31,10 +31,16 @@ type serverConn struct {
state serverState
handshakeLock sync.Mutex
password string
rsaPrivateKey *rsa.PrivateKey
rsaPublicKey []byte
handshakeLock sync.Mutex
lifecycleMu sync.Mutex
closed bool
profiles []loginProfile
password string
rsaPrivateKey *rsa.PrivateKey
rsaPublicKey []byte
paddingSchedule []paddingTurn
packet *packetStream
deadlines *connectionDeadlines
}
func (c *serverConn) handshake() error {
@@ -45,12 +51,10 @@ func (c *serverConn) handshake() error {
return nil
}
// handshake timeout
err := c.c.SetDeadline(time.Now().Add(time.Second * 30))
if err != nil {
if err := c.deadlines.beginHandshake(); err != nil {
return fmt.Errorf("set deadline: %w", err)
}
defer c.c.SetDeadline(time.Time{})
defer func() { _ = c.deadlines.endHandshake() }()
var (
protocolVersion Varint
@@ -138,17 +142,20 @@ func (c *serverConn) handshake() error {
if err != nil {
return fmt.Errorf("read login start packet: %w", err)
}
profile, found := findProfile(c.profiles, string(username), uuid)
// encrypt request
var (
serverId String = String("")
publicKey Bytes = Bytes(c.rsaPublicKey)
verifyToken Bytes = Bytes(make([]byte, 4))
shouldAuthenticate Varint = Varint(1)
serverId String = String("")
publicKey Bytes = Bytes(c.rsaPublicKey)
verifyToken Bytes = Bytes(make([]byte, 4))
shouldAuthenticate Boolean = true
)
rand.Read(verifyToken)
if _, err = rand.Read(verifyToken); err != nil {
return fmt.Errorf("generate verify token: %w", err)
}
err = writePacket(c.writer, 0x01, &serverId, &publicKey, &verifyToken, &shouldAuthenticate)
if err != nil {
@@ -183,6 +190,9 @@ func (c *serverConn) handshake() error {
if err != nil {
return fmt.Errorf("decrypt shared secret: %w", err)
}
if len(sharedSecret) != 16 {
return fmt.Errorf("bad shared secret length: %d", len(sharedSecret))
}
decryptedVerifyToken, err = rsa.DecryptPKCS1v15(rand.Reader, c.rsaPrivateKey, encryptedVerifyToken)
if err != nil {
@@ -210,8 +220,47 @@ func (c *serverConn) handshake() error {
writeDisconnectPacket(c.writer, `{"type":"translatable","translate":"multiplayer.disconnect.authservers_down"}`)
return fmt.Errorf("bad password")
}
if !found {
if err = writeDisconnectPacket(c.writer, `{"text":"You are not white-listed on this server!"}`); err != nil {
return fmt.Errorf("write unknown login profile disconnect: %w", err)
}
return fmt.Errorf("unknown login profile")
}
loginName := String(profile.Username)
propertyCount := Varint(1)
propertyName := String("textures")
texturesValue := String(profile.TexturesValue)
signed := Boolean(true)
texturesSignature := String(profile.TexturesSignature)
if err = writePacket(c.writer, 0x02, &profile.UUID, &loginName, &propertyCount, &propertyName, &texturesValue, &signed, &texturesSignature); err != nil {
return fmt.Errorf("write login finished: %w", err)
}
var loginAcknowledgedLength int
pkt, loginAcknowledgedLength, err = readPacketWithLength(c.reader)
if err != nil {
return fmt.Errorf("read login acknowledged: %w", err)
}
if err = validateLoginAcknowledgedPacket(pkt); err != nil {
return err
}
if err = runPaddingSchedule(c.reader, c.writer, false, loginAcknowledgedLength, c.paddingSchedule); err != nil {
return fmt.Errorf("run startup padding: %w", err)
}
packet := newPacketStream(c.reader, c.writer, false)
c.lifecycleMu.Lock()
if c.closed {
c.lifecycleMu.Unlock()
packet.Stop()
return net.ErrClosed
}
c.packet = packet
c.reader = packet
c.writer = packet
c.state = serverStateProxy
c.lifecycleMu.Unlock()
return nil
@@ -220,6 +269,16 @@ func (c *serverConn) handshake() error {
}
}
func validateLoginAcknowledgedPacket(pkt *mcPacket) error {
if pkt.packetID != 0x03 {
return fmt.Errorf("bad login acknowledged packet id: %d", pkt.packetID)
}
if len(pkt.data) != 0 {
return fmt.Errorf("bad login acknowledged packet data length: %d", len(pkt.data))
}
return nil
}
func (c *serverConn) Read(b []byte) (int, error) {
err := c.handshake()
if err != nil {
@@ -239,6 +298,13 @@ func (c *serverConn) Write(b []byte) (int, error) {
}
func (c *serverConn) Close() error {
c.lifecycleMu.Lock()
c.closed = true
packet := c.packet
c.lifecycleMu.Unlock()
if packet != nil {
packet.Stop()
}
return c.c.Close()
}
@@ -251,38 +317,47 @@ func (c *serverConn) RemoteAddr() net.Addr {
}
func (c *serverConn) SetDeadline(t time.Time) error {
return c.c.SetDeadline(t)
return c.deadlines.setDeadline(t)
}
func (c *serverConn) SetReadDeadline(t time.Time) error {
return c.c.SetReadDeadline(t)
return c.deadlines.setReadDeadline(t)
}
func (c *serverConn) SetWriteDeadline(t time.Time) error {
return c.c.SetWriteDeadline(t)
return c.deadlines.setWriteDeadline(t)
}
func wrapConnServer(c net.Conn, password string, rsaPrivateKeyDER []byte, rsaPublicKey []byte) (*serverConn, error) {
func wrapConnServer(c net.Conn, profiles []loginProfile, password string, rsaPrivateKeyDER []byte, rsaPublicKey []byte) (*serverConn, error) {
if len(profiles) == 0 {
return nil, fmt.Errorf("empty profiles")
}
if len(rsaPrivateKeyDER) == 0 {
return nil, fmt.Errorf("empty rsa private key")
}
if len(rsaPublicKey) == 0 {
return nil, fmt.Errorf("empty rsa public key")
}
rsaPrivateKey, err := x509.ParsePKCS1PrivateKey(rsaPrivateKeyDER)
if err != nil {
return nil, fmt.Errorf("parse rsa private key: %w", err)
}
paddingSchedule, err := newServerPaddingSchedule2612()
if err != nil {
return nil, fmt.Errorf("select padding profile: %w", err)
}
s := &serverConn{
reader: bufio.NewReader(c),
writer: c,
c: c,
state: serverStateHandshake,
password: password,
rsaPrivateKey: rsaPrivateKey,
rsaPublicKey: rsaPublicKey,
reader: bufio.NewReader(c),
writer: c,
c: c,
state: serverStateHandshake,
profiles: profiles,
password: password,
rsaPrivateKey: rsaPrivateKey,
rsaPublicKey: rsaPublicKey,
paddingSchedule: paddingSchedule,
deadlines: newConnectionDeadlines(c),
}
return s, nil
+19 -8
View File
@@ -6,12 +6,14 @@ import (
"crypto/cipher"
"fmt"
"io"
"sync"
)
type cryptoStream struct {
stream cipher.Stream
r io.Reader
w io.Writer
mu sync.Mutex
}
func newCryptoReader(r io.Reader, sharedSecret []byte) (*cryptoStream, error) {
@@ -30,12 +32,19 @@ func (c *cryptoStream) Read(b []byte) (int, error) {
panic("read on a write-only crypto stream")
}
n, err := c.r.Read(b)
if err != nil {
return 0, fmt.Errorf("crypto reader: read: %w", err)
}
c.mu.Lock()
defer c.mu.Unlock()
c.stream.XORKeyStream(b[:n], b[:n])
n, err := c.r.Read(b)
if n > 0 {
c.stream.XORKeyStream(b[:n], b[:n])
}
if err != nil {
if err == io.EOF {
return n, io.EOF
}
return n, fmt.Errorf("crypto reader: read: %w", err)
}
return n, nil
}
@@ -56,13 +65,15 @@ func (c *cryptoStream) Write(b []byte) (int, error) {
panic("write on a read-only crypto stream")
}
c.mu.Lock()
defer c.mu.Unlock()
encrypted := make([]byte, len(b))
c.stream.XORKeyStream(encrypted, b)
n, err := c.w.Write(encrypted)
if err != nil {
if err := writeFull(c.w, encrypted); err != nil {
return 0, fmt.Errorf("crypto writer: write: %w", err)
}
return n, nil
return len(b), nil
}
@@ -0,0 +1,81 @@
package xmc
import (
"bytes"
"io"
"testing"
)
type dataAndEOFReader struct {
data []byte
}
func (r *dataAndEOFReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.EOF
}
n := copy(p, r.data)
r.data = r.data[n:]
return n, io.EOF
}
type shortWriter struct {
bytes.Buffer
}
func (w *shortWriter) Write(p []byte) (int, error) {
if len(p) > 1 {
p = p[:len(p)/2]
}
return w.Buffer.Write(p)
}
func TestCryptoReaderPreservesDataReturnedWithEOF(t *testing.T) {
secret := []byte("0123456789abcdef")
plaintext := []byte("payload returned with EOF")
var encrypted bytes.Buffer
writer, err := newCryptoWriter(&encrypted, secret)
if err != nil {
t.Fatal(err)
}
if _, err = writer.Write(plaintext); err != nil {
t.Fatal(err)
}
reader, err := newCryptoReader(&dataAndEOFReader{data: encrypted.Bytes()}, secret)
if err != nil {
t.Fatal(err)
}
got := make([]byte, len(plaintext))
n, err := reader.Read(got)
if err == nil || n != len(plaintext) {
t.Fatalf("Read = %d, %v", n, err)
}
if !bytes.Equal(got[:n], plaintext) {
t.Fatalf("plaintext = %q", got[:n])
}
}
func TestCryptoWriterHandlesShortWrites(t *testing.T) {
secret := []byte("0123456789abcdef")
plaintext := bytes.Repeat([]byte("short-write"), 100)
var dst shortWriter
writer, err := newCryptoWriter(&dst, secret)
if err != nil {
t.Fatal(err)
}
if n, err := writer.Write(plaintext); err != nil || n != len(plaintext) {
t.Fatalf("Write = %d, %v", n, err)
}
reader, err := newCryptoReader(bytes.NewReader(dst.Bytes()), secret)
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, plaintext) {
t.Fatal("decrypted payload mismatch")
}
}