diff --git a/infra/conf/transport_finalmask.go b/infra/conf/transport_finalmask.go index 24c3f0afae8f..e4c579dda117 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 000000000000..5d4257456fc8 --- /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 c0d1ee2b48c4..2f351858fec0 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" @@ -24,10 +23,11 @@ type clientConn struct { state clientState handshakeLock sync.Mutex - usernames []string + profiles []loginProfile password string rsaPublicKey []byte hostname string + packet *packetStream } type clientState int @@ -37,18 +37,20 @@ 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") + } return &clientConn{ reader: bufio.NewReader(c), writer: c, c: c, state: clientStateHandshake, handshakeLock: sync.Mutex{}, - usernames: usernames, + profiles: profiles, password: password, rsaPublicKey: rsaPublicKey, hostname: hostname, @@ -95,16 +97,14 @@ func (c *clientConn) handshake() error { } // Login Start - var ( - username string - offlineUUID UUID - ) - - randomUsername, _ := rand.Int(rand.Reader, big.NewInt(int64(len(c.usernames)))) - username = c.usernames[randomUsername.Int64()] - generateOfflineUUID(&offlineUUID, string(username)) + 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) - 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 +145,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,6 +183,36 @@ func (c *clientConn) handshake() error { return fmt.Errorf("new crypto writer: %w", err) } + pkt, err = readPacket(c.reader) + if err != nil { + return fmt.Errorf("read login success: %w", err) + } + if pkt.packetID == 0x00 { + var reason String + if readErr := pkt.readFields(&reason); readErr != nil { + return fmt.Errorf("authentication rejected") + } + return fmt.Errorf("authentication rejected: %s", reason) + } + if pkt.packetID != 0x02 { + return fmt.Errorf("bad login success packet id: %d", pkt.packetID) + } + + receivedProfile, err := readLoginSuccess(pkt) + if err != nil { + return fmt.Errorf("read login success fields: %w", err) + } + if receivedProfile != selectedProfile { + return fmt.Errorf("login profile mismatch") + } + if err = writePacket(c.writer, 0x03); err != nil { + return fmt.Errorf("write login acknowledged: %w", err) + } + + c.packet = newPacketStream(c.reader, c.writer, true) + c.reader = c.packet + c.writer = c.packet + c.state = clientStateProxy return nil @@ -205,6 +237,9 @@ func (c *clientConn) Write(b []byte) (int, error) { } func (c *clientConn) Close() error { + if c.packet != nil { + c.packet.Stop() + } return c.c.Close() } @@ -227,10 +262,3 @@ func (c *clientConn) SetReadDeadline(t time.Time) error { 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 -} diff --git a/transport/internet/finalmask/xmc/config.go b/transport/internet/finalmask/xmc/config.go index d0f414a7c456..be3a1ef6c733 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 f4c983b849c8..e12c6ffe7c13 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 25fe29d4b24e..c880439d01c4 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/handshake_test.go b/transport/internet/finalmask/xmc/handshake_test.go index 2e48c309c4d0..d766eb382e66 100644 --- a/transport/internet/finalmask/xmc/handshake_test.go +++ b/transport/internet/finalmask/xmc/handshake_test.go @@ -2,6 +2,7 @@ package xmc import ( "bytes" + "crypto/sha256" "crypto/x509" "net" "sync" @@ -24,6 +25,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: "test-textures-value", + TexturesSignature: "test-textures-signature", + } + 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 +46,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 +56,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 +87,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 +117,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 +131,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,18 +153,16 @@ 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 diff --git a/transport/internet/finalmask/xmc/packet_stream.go b/transport/internet/finalmask/xmc/packet_stream.go new file mode 100644 index 000000000000..bb4f937eb8bf --- /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 000000000000..3bfd80dc42e7 --- /dev/null +++ b/transport/internet/finalmask/xmc/packet_stream_test.go @@ -0,0 +1,73 @@ +package xmc + +import ( + "bytes" + "io" + "net" + "testing" +) + +func TestPacketStreamRoundTrip(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + const password = "packet-stream-shared-key" + privateKey, publicKey := deriveTestRSAKey(t, password) + 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/profile.go b/transport/internet/finalmask/xmc/profile.go new file mode 100644 index 000000000000..6ef55349b969 --- /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 000000000000..35dd412864ee --- /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 68a4fb74df29..e09e5cde7970 100644 --- a/transport/internet/finalmask/xmc/protocol.go +++ b/transport/internet/finalmask/xmc/protocol.go @@ -23,20 +23,28 @@ func readPacket(b io.Reader) (*mcPacket, error) { if err != nil { return nil, fmt.Errorf("read packet length: %w", err) } + if packetLength < 1 || packetLength > 1024*32+5 { + return nil, fmt.Errorf("read packet: bad length: %d", packetLength) + } + packetData := make([]byte, int(packetLength)) + if _, err = io.ReadFull(b, packetData); err != nil { + return nil, fmt.Errorf("read packet data: %w", err) + } + 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 > 1024*32 { 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) } @@ -49,7 +57,6 @@ func readPacket(b io.Reader) (*mcPacket, error) { func (p *mcPacket) readFields(fields ...field) error { r := bytes.NewReader(p.data) - for _, field := range fields { err := field.readFrom(r) if err != nil { @@ -62,10 +69,12 @@ func (p *mcPacket) readFields(fields ...field) error { type Varint int32 -func (v *Varint) readFrom(r io.Reader) error { - SEGMENT_BITS := byte(0x7F) - CONTINUE_BIT := byte(0x80) +const ( + SEGMENT_BITS = 0x7F + CONTINUE_BIT = 0x80 +) +func (v *Varint) readFrom(r io.Reader) error { var err error var value int32 = 0 @@ -96,13 +105,10 @@ func (v *Varint) readFrom(r io.Reader) error { } func (v *Varint) writeTo(w io.Writer) error { - SEGMENT_BITS := byte(0x7F) - CONTINUE_BIT := byte(0x80) - - value := int32(*v) + 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 +128,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 +245,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 +288,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 +303,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) @@ -322,14 +372,29 @@ func writePacket(w io.Writer, packetID int, fields ...field) error { buf.Write(dataBuf.Bytes()) - _, err = w.Write(buf.Bytes()) - if err != nil { + if err = writeFull(w, buf.Bytes()); err != nil { return fmt.Errorf("write packet data: %w", err) } return 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 +} + func writeDisconnectPacket(w io.Writer, reason string) error { return writePacket(w, 0x00, new(String(reason))) } diff --git a/transport/internet/finalmask/xmc/protocol_test.go b/transport/internet/finalmask/xmc/protocol_test.go new file mode 100644 index 000000000000..5b1d2bfd76b4 --- /dev/null +++ b/transport/internet/finalmask/xmc/protocol_test.go @@ -0,0 +1,21 @@ +package xmc + +import ( + "bytes" + "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) + } +} diff --git a/transport/internet/finalmask/xmc/server.go b/transport/internet/finalmask/xmc/server.go index c79be767d8e7..5ecb44871db7 100644 --- a/transport/internet/finalmask/xmc/server.go +++ b/transport/internet/finalmask/xmc/server.go @@ -32,9 +32,11 @@ type serverConn struct { state serverState handshakeLock sync.Mutex + profiles []loginProfile password string rsaPrivateKey *rsa.PrivateKey rsaPublicKey []byte + packet *packetStream } func (c *serverConn) handshake() error { @@ -138,17 +140,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 +188,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,6 +218,34 @@ 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 success: %w", err) + } + + pkt, err = readPacket(c.reader) + if err != nil { + return fmt.Errorf("read login acknowledged: %w", err) + } + if pkt.packetID != 0x03 { + return fmt.Errorf("bad login acknowledged packet id: %d", pkt.packetID) + } + + c.packet = newPacketStream(c.reader, c.writer, false) + c.reader = c.packet + c.writer = c.packet c.state = serverStateProxy @@ -239,6 +275,9 @@ func (c *serverConn) Write(b []byte) (int, error) { } func (c *serverConn) Close() error { + if c.packet != nil { + c.packet.Stop() + } return c.c.Close() } @@ -262,14 +301,16 @@ func (c *serverConn) SetWriteDeadline(t time.Time) error { return c.c.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) @@ -280,6 +321,7 @@ func wrapConnServer(c net.Conn, password string, rsaPrivateKeyDER []byte, rsaPub writer: c, c: c, state: serverStateHandshake, + profiles: profiles, password: password, rsaPrivateKey: rsaPrivateKey, rsaPublicKey: rsaPublicKey, diff --git a/transport/internet/finalmask/xmc/stream.go b/transport/internet/finalmask/xmc/stream.go index 2849a55eaf59..e44746949d7d 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,13 +32,20 @@ func (c *cryptoStream) Read(b []byte) (int, error) { panic("read on a write-only crypto stream") } + c.mu.Lock() + defer c.mu.Unlock() + n, err := c.r.Read(b) + if n > 0 { + c.stream.XORKeyStream(b[:n], b[:n]) + } if err != nil { - return 0, fmt.Errorf("crypto reader: read: %w", err) + if err == io.EOF { + return n, io.EOF + } + return n, fmt.Errorf("crypto reader: read: %w", err) } - c.stream.XORKeyStream(b[:n], b[:n]) - 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 000000000000..bd75d7b86534 --- /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") + } +}