diff --git a/infra/conf/transport_finalmask.go b/infra/conf/transport_finalmask.go index 24c3f0afa..e4c579dda 100644 --- a/infra/conf/transport_finalmask.go +++ b/infra/conf/transport_finalmask.go @@ -11,6 +11,7 @@ import ( "regexp" "strings" + googleuuid "github.com/google/uuid" "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/net" "github.com/xtls/xray-core/transport/internet/finalmask/fragment" @@ -720,14 +721,46 @@ func (c *Xdns) Build() (proto.Message, error) { } type XMC struct { - Hostname string `json:"hostname"` - Usernames []string `json:"usernames"` - Password string `json:"password"` + Hostname string `json:"hostname"` + Profiles []XMCProfile `json:"profiles"` + Password string `json:"password"` +} + +type XMCProfile struct { + // Resolve the UUID by username, then request the session profile with + // unsigned=false. Client and server must use the same signed profile. + Username string `json:"username"` + UUID string `json:"uuid"` + TexturesValue string `json:"texturesValue"` + TexturesSignature string `json:"texturesSignature"` +} + +var xmcUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`) + +func (c *XMCProfile) Build() (*xmc.Profile, error) { + if !xmcUsernamePattern.MatchString(c.Username) { + return nil, fmt.Errorf("invalid minecraft profile username: %q", c.Username) + } + + profileUUID, err := googleuuid.Parse(c.UUID) + if err != nil { + return nil, fmt.Errorf("invalid minecraft profile UUID: %w", err) + } + if c.TexturesValue == "" || c.TexturesSignature == "" { + return nil, fmt.Errorf("incomplete minecraft profile textures") + } + + return &xmc.Profile{ + Username: c.Username, + Uuid: append([]byte(nil), profileUUID[:]...), + TexturesValue: c.TexturesValue, + TexturesSignature: c.TexturesSignature, + }, nil } func (c *XMC) Build() (proto.Message, error) { - if len(c.Usernames) == 0 { - c.Usernames = []string{"Dream"} + if len(c.Profiles) == 0 { + return nil, fmt.Errorf("minecraft profiles are required") } if c.Password == "" { @@ -744,12 +777,21 @@ func (c *XMC) Build() (proto.Message, error) { return nil, fmt.Errorf("marshal minecraft rsa public key: %w", err) } + profiles := make([]*xmc.Profile, 0, len(c.Profiles)) + for i := range c.Profiles { + profile, err := c.Profiles[i].Build() + if err != nil { + return nil, fmt.Errorf("build minecraft profile %d: %w", i, err) + } + profiles = append(profiles, profile) + } + return &xmc.Config{ Password: c.Password, - Usernames: c.Usernames, Hostname: c.Hostname, RsaPrivateKey: x509.MarshalPKCS1PrivateKey(rsaPrivateKey), RsaPublicKey: rsaPublicKey, + Profiles: profiles, }, nil } diff --git a/infra/conf/transport_finalmask_xmc_test.go b/infra/conf/transport_finalmask_xmc_test.go new file mode 100644 index 000000000..5d4257456 --- /dev/null +++ b/infra/conf/transport_finalmask_xmc_test.go @@ -0,0 +1,36 @@ +package conf + +import ( + "strings" + "testing" + + "github.com/xtls/xray-core/transport/internet/finalmask/xmc" +) + +func TestXMCBuildProfile(t *testing.T) { + built, err := (&XMC{ + Password: "test-password", + Profiles: []XMCProfile{ + { + Username: "TestUser", + UUID: "00112233-4455-6677-8899-aabbccddeeff", + TexturesValue: "textures-value", + TexturesSignature: "textures-signature", + }, + }, + }).Build() + if err != nil { + t.Fatalf("build XMC config: %v", err) + } + config := built.(*xmc.Config) + if len(config.Profiles) != 1 || len(config.Profiles[0].Uuid) != 16 { + t.Fatalf("unexpected profiles: %+v", config.Profiles) + } +} + +func TestXMCBuildRequiresProfile(t *testing.T) { + _, err := (&XMC{Password: "test-password"}).Build() + if err == nil || !strings.Contains(err.Error(), "profiles are required") { + t.Fatalf("expected required profiles error, got %v", err) + } +} diff --git a/transport/internet/finalmask/xmc/client.go b/transport/internet/finalmask/xmc/client.go index c0d1ee2b4..6159d09a1 100644 --- a/transport/internet/finalmask/xmc/client.go +++ b/transport/internet/finalmask/xmc/client.go @@ -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) } diff --git a/transport/internet/finalmask/xmc/config.go b/transport/internet/finalmask/xmc/config.go index d0f414a7c..be3a1ef6c 100644 --- a/transport/internet/finalmask/xmc/config.go +++ b/transport/internet/finalmask/xmc/config.go @@ -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) } diff --git a/transport/internet/finalmask/xmc/config.pb.go b/transport/internet/finalmask/xmc/config.pb.go index f4c983b84..e12c6ffe7 100644 --- a/transport/internet/finalmask/xmc/config.pb.go +++ b/transport/internet/finalmask/xmc/config.pb.go @@ -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, }, diff --git a/transport/internet/finalmask/xmc/config.proto b/transport/internet/finalmask/xmc/config.proto index 25fe29d4b..c880439d0 100644 --- a/transport/internet/finalmask/xmc/config.proto +++ b/transport/internet/finalmask/xmc/config.proto @@ -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; } diff --git a/transport/internet/finalmask/xmc/deadline.go b/transport/internet/finalmask/xmc/deadline.go new file mode 100644 index 000000000..59380183a --- /dev/null +++ b/transport/internet/finalmask/xmc/deadline.go @@ -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 +} diff --git a/transport/internet/finalmask/xmc/deadline_test.go b/transport/internet/finalmask/xmc/deadline_test.go new file mode 100644 index 000000000..2a56c3e2c --- /dev/null +++ b/transport/internet/finalmask/xmc/deadline_test.go @@ -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 +} diff --git a/transport/internet/finalmask/xmc/handshake_test.go b/transport/internet/finalmask/xmc/handshake_test.go index 2e48c309c..b2db51ef9 100644 --- a/transport/internet/finalmask/xmc/handshake_test.go +++ b/transport/internet/finalmask/xmc/handshake_test.go @@ -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") + } +} diff --git a/transport/internet/finalmask/xmc/packet_stream.go b/transport/internet/finalmask/xmc/packet_stream.go new file mode 100644 index 000000000..bb4f937eb --- /dev/null +++ b/transport/internet/finalmask/xmc/packet_stream.go @@ -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 + } + } +} diff --git a/transport/internet/finalmask/xmc/packet_stream_test.go b/transport/internet/finalmask/xmc/packet_stream_test.go new file mode 100644 index 000000000..ea605249f --- /dev/null +++ b/transport/internet/finalmask/xmc/packet_stream_test.go @@ -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) + } +} diff --git a/transport/internet/finalmask/xmc/padding.go b/transport/internet/finalmask/xmc/padding.go new file mode 100644 index 000000000..eb7114a09 --- /dev/null +++ b/transport/internet/finalmask/xmc/padding.go @@ -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 +} diff --git a/transport/internet/finalmask/xmc/padding_preset.go b/transport/internet/finalmask/xmc/padding_preset.go new file mode 100644 index 000000000..b21015545 --- /dev/null +++ b/transport/internet/finalmask/xmc/padding_preset.go @@ -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< 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) +} diff --git a/transport/internet/finalmask/xmc/profile.go b/transport/internet/finalmask/xmc/profile.go new file mode 100644 index 000000000..6ef55349b --- /dev/null +++ b/transport/internet/finalmask/xmc/profile.go @@ -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 +} diff --git a/transport/internet/finalmask/xmc/profile_test.go b/transport/internet/finalmask/xmc/profile_test.go new file mode 100644 index 000000000..35dd41286 --- /dev/null +++ b/transport/internet/finalmask/xmc/profile_test.go @@ -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]) + } +} diff --git a/transport/internet/finalmask/xmc/protocol.go b/transport/internet/finalmask/xmc/protocol.go index 68a4fb74d..a636d413d 100644 --- a/transport/internet/finalmask/xmc/protocol.go +++ b/transport/internet/finalmask/xmc/protocol.go @@ -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 } diff --git a/transport/internet/finalmask/xmc/protocol_test.go b/transport/internet/finalmask/xmc/protocol_test.go new file mode 100644 index 000000000..3aa68d488 --- /dev/null +++ b/transport/internet/finalmask/xmc/protocol_test.go @@ -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) + } +} diff --git a/transport/internet/finalmask/xmc/server.go b/transport/internet/finalmask/xmc/server.go index c79be767d..43fba9593 100644 --- a/transport/internet/finalmask/xmc/server.go +++ b/transport/internet/finalmask/xmc/server.go @@ -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 diff --git a/transport/internet/finalmask/xmc/stream.go b/transport/internet/finalmask/xmc/stream.go index 2849a55ea..e44746949 100644 --- a/transport/internet/finalmask/xmc/stream.go +++ b/transport/internet/finalmask/xmc/stream.go @@ -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 } diff --git a/transport/internet/finalmask/xmc/stream_test.go b/transport/internet/finalmask/xmc/stream_test.go new file mode 100644 index 000000000..bd75d7b86 --- /dev/null +++ b/transport/internet/finalmask/xmc/stream_test.go @@ -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") + } +}