diff --git a/infra/conf/transport_finalmask.go b/infra/conf/transport_finalmask.go index bdcf8294e933..24c3f0afae8f 100644 --- a/infra/conf/transport_finalmask.go +++ b/infra/conf/transport_finalmask.go @@ -1,9 +1,11 @@ package conf import ( + "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" + "fmt" "net/netip" "net/url" "regexp" @@ -22,6 +24,7 @@ import ( "github.com/xtls/xray-core/transport/internet/finalmask/sudoku" "github.com/xtls/xray-core/transport/internet/finalmask/xdns" "github.com/xtls/xray-core/transport/internet/finalmask/xicmp" + "github.com/xtls/xray-core/transport/internet/finalmask/xmc" "github.com/xtls/xray-core/transport/internet/tls" "google.golang.org/protobuf/proto" ) @@ -67,6 +70,7 @@ var ( "header-custom": func() interface{} { return new(HeaderCustomTCP) }, "fragment": func() interface{} { return new(FragmentMask) }, "sudoku": func() interface{} { return new(Sudoku) }, + "xmc": func() interface{} { return new(XMC) }, }, "type", "settings") udpmaskLoader = NewJSONConfigLoader(ConfigCreatorCache{ @@ -715,6 +719,40 @@ func (c *Xdns) Build() (proto.Message, error) { }, nil } +type XMC struct { + Hostname string `json:"hostname"` + Usernames []string `json:"usernames"` + Password string `json:"password"` +} + +func (c *XMC) Build() (proto.Message, error) { + if len(c.Usernames) == 0 { + c.Usernames = []string{"Dream"} + } + + if c.Password == "" { + return nil, fmt.Errorf("empty password") + } + + rsaPrivateKey, err := xmc.DeriveRSAKey(c.Password) + if err != nil { + return nil, fmt.Errorf("derive minecraft rsa key: %w", err) + } + + rsaPublicKey, err := x509.MarshalPKIXPublicKey(&rsaPrivateKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("marshal minecraft rsa public key: %w", err) + } + + return &xmc.Config{ + Password: c.Password, + Usernames: c.Usernames, + Hostname: c.Hostname, + RsaPrivateKey: x509.MarshalPKCS1PrivateKey(rsaPrivateKey), + RsaPublicKey: rsaPublicKey, + }, nil +} + type Xicmp struct { DGRAM bool `json:"dgram"` IPs []string `json:"ips"` diff --git a/transport/internet/finalmask/xmc/cfb8.go b/transport/internet/finalmask/xmc/cfb8.go new file mode 100644 index 000000000000..ed9cc1c53685 --- /dev/null +++ b/transport/internet/finalmask/xmc/cfb8.go @@ -0,0 +1,138 @@ +package xmc + +// Copied from https://github.com/Tnze/go-mc/blob/539b4a3a7f030332eb58b8a946116ae7907630d2/net/CFB8/cfb8.go + +import ( + "crypto/cipher" + "crypto/subtle" + "unsafe" +) + +type cfb8 struct { + c cipher.Block + blockSize int + ivPos int + iv []byte + de bool +} + +func newCFB8Decrypt(c cipher.Block, iv []byte) *cfb8 { + return newCFB8(c, iv, true) +} + +func newCFB8Encrypt(c cipher.Block, iv []byte) *cfb8 { + return newCFB8(c, iv, false) +} + +func newCFB8(c cipher.Block, iv []byte, de bool) *cfb8 { + cp := make([]byte, len(iv)*3) + copy(cp, iv) + return &cfb8{ + c: c, + blockSize: c.BlockSize(), + iv: cp, + de: de, + } +} + +func (cf *cfb8) XORKeyStream(dst, src []byte) { + if len(src) == 0 { + return + } + if len(dst) < len(src) { + panic("cfb8: output smaller than input") + } + + // If dst and src does not overlap in first block size, + // and the length of src is greater than 2*blockSize, + // we can use an optimized implementation. + if len(src) > cf.blockSize<<1 && + (uintptr(unsafe.Pointer(&dst[0]))+uintptr(cf.blockSize) <= uintptr(unsafe.Pointer(&src[0])) || + uintptr(unsafe.Pointer(&src[0]))+uintptr(len(src)) <= uintptr(unsafe.Pointer(&dst[0]))) { + // encrypt/decrypt first blockSize bytes + // After this, the IV will come to the same as + // the last blockSize of ciphertext, so + // we can reuse them without copy. + cf.xorKeyStream(dst, src[:cf.blockSize]) + var ciphertext []byte + if cf.de { + ciphertext = src + } else { + ciphertext = dst + } + dst = dst[cf.blockSize:] + src = src[cf.blockSize:] + iv := cf.iv + _ = iv[0] // bounds check hint to compiler; see golang.org/issue/14808 + var ( + i int + val byte + ) + dst = dst[:len(src)] + if cf.de && // and requires to be non-overlapping at all + uintptr(unsafe.Pointer(&dst[0])) <= uintptr(unsafe.Pointer(&src[len(src)-1])) && + uintptr(unsafe.Pointer(&src[0])) <= uintptr(unsafe.Pointer(&dst[len(dst)-1])) { + for i = 0; i < len(src)-cf.blockSize; i += 1 { + cf.c.Encrypt(dst[i:], ciphertext[i:]) + } + subtle.XORBytes(dst, src[:i], dst) + for ; i < len(src); i += 1 { + cf.c.Encrypt(iv, ciphertext[i:]) + dst[i] = src[i] ^ iv[0] + } + } else { + _ = ciphertext[len(src)] + for i, val = range src { + cf.c.Encrypt(iv, ciphertext[i:]) + dst[i] = val ^ iv[0] + } + // for-range does not increase i in the last loop, + // compared to the classic for clause + i += 1 + } + // copy the current IV for next operation + copy(iv, ciphertext[i:i+cf.blockSize]) + cf.ivPos = 0 + return + } + + cf.xorKeyStream(dst, src) +} + +func (cf *cfb8) xorKeyStream(dst, src []byte) { + dst = dst[:len(src)] // remove bounds check in loop + for i, val := range src { + posPlusBlockSize := cf.ivPos + cf.blockSize + // fast mod; 2*blockSize must be a non-negative integer power of 2 + tempPos := posPlusBlockSize & (cf.blockSize<<1 - 1) + // reuse space to store encrypted block + cf.c.Encrypt(cf.iv[tempPos:], cf.iv[cf.ivPos:]) + // Only the first byte of the encrypted block is used + // for encryption/decryption, other bytes are ignored. + val ^= cf.iv[tempPos] + + if cf.ivPos == cf.blockSize<<1 { + // bound reached; move to next round for next operation + // copy next block to the start of the ring buffer + copy(cf.iv, cf.iv[cf.ivPos+1:]) + // insert the encrypted byte to the end of IV + if cf.de { + cf.iv[cf.blockSize-1] = src[i] + } else { + cf.iv[cf.blockSize-1] = val + } + cf.ivPos = 0 + } else { + // insert the encrypted byte to the end of IV + if cf.de { + cf.iv[posPlusBlockSize] = src[i] + } else { + cf.iv[posPlusBlockSize] = val + } + // move to next block + cf.ivPos += 1 + } + + dst[i] = val + } +} diff --git a/transport/internet/finalmask/xmc/client.go b/transport/internet/finalmask/xmc/client.go new file mode 100644 index 000000000000..c0d1ee2b48c4 --- /dev/null +++ b/transport/internet/finalmask/xmc/client.go @@ -0,0 +1,236 @@ +package xmc + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "fmt" + "io" + "math/big" + "net" + "strconv" + "sync" + "time" +) + +type clientConn struct { + reader io.Reader + writer io.Writer + c net.Conn + + state clientState + + handshakeLock sync.Mutex + usernames []string + password string + rsaPublicKey []byte + hostname string +} + +type clientState int + +var ( + clientStateHandshake clientState = 1 + clientStateProxy clientState = 2 +) + +func newClientConn(c net.Conn, usernames []string, password string, rsaPublicKey []byte, hostname string) (*clientConn, error) { + if len(rsaPublicKey) == 0 { + return nil, fmt.Errorf("empty rsa public key") + } + + return &clientConn{ + reader: bufio.NewReader(c), + writer: c, + c: c, + state: clientStateHandshake, + handshakeLock: sync.Mutex{}, + usernames: usernames, + password: password, + rsaPublicKey: rsaPublicKey, + hostname: hostname, + }, nil +} + +func (c *clientConn) handshake() error { + c.handshakeLock.Lock() + defer c.handshakeLock.Unlock() + + if c.state != clientStateHandshake { + return nil + } + + // Handshake timeout + err := c.c.SetDeadline(time.Now().Add(time.Second * 30)) + if err != nil { + return fmt.Errorf("set deadline: %w", err) + } + defer c.c.SetDeadline(time.Time{}) + + var ( + protocolVersion Varint = Varint(775) + serverAddress String = String(c.hostname) + serverPort UnsignedShort = UnsignedShort(25565) + nextState Varint = Varint(2) + ) + + host, portString, err := net.SplitHostPort(c.c.RemoteAddr().String()) + if err == nil { + port, err := strconv.Atoi(portString) + if err == nil { + serverPort = UnsignedShort(port) + } + + if serverAddress == "" { + serverAddress = String(host) + } + } + + err = writePacket(c.writer, 0x00, &protocolVersion, &serverAddress, &serverPort, &nextState) + if err != nil { + return fmt.Errorf("write handshake packet: %w", err) + } + + // 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)) + + err = writePacket(c.writer, 0x00, new(String(username)), &offlineUUID) + if err != nil { + return fmt.Errorf("write login start: %w", err) + } + + // Encryption Request + pkt, err := readPacket(c.reader) + if err != nil { + return fmt.Errorf("read encryption request: %w", err) + } + + if pkt.packetID != 0x01 { + return fmt.Errorf("bad encrypt request packet id") + } + + var ( + serverId String + publicKey Bytes + verifyToken Bytes + ) + + err = pkt.readFields(&serverId, &publicKey, &verifyToken) + if err != nil { + return fmt.Errorf("read encryption request fields: %w", err) + } + + if !bytes.Equal(publicKey, c.rsaPublicKey) { + return fmt.Errorf("server public key mismatch") + } + + k, err := x509.ParsePKIXPublicKey(publicKey) + if err != nil { + return fmt.Errorf("parse server public key: %w", err) + } + + rsaPublicKey, ok := k.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("parse server public key: not rsa") + } + + sharedSecret := make([]byte, 16) + rand.Read(sharedSecret) + + encryptedSharedSecret, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPublicKey, sharedSecret) + if err != nil { + return fmt.Errorf("encrypt shared secret: %w", err) + } + + verifyToken = append(verifyToken, []byte(c.password)...) // append pre-shared password + + encryptedVerifyToken, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPublicKey, verifyToken) + if err != nil { + return fmt.Errorf("encrypt verify token: %w", err) + } + + // Send Encryption Response + err = writePacket( + c.writer, + 0x01, + (*Bytes)(&encryptedSharedSecret), + (*Bytes)(&encryptedVerifyToken), + ) + if err != nil { + return fmt.Errorf("write encryption response: %w", err) + } + + // Enable encryption + c.reader, err = newCryptoReader(c.reader, sharedSecret) + if err != nil { + return fmt.Errorf("new crypto reader: %w", err) + } + + c.writer, err = newCryptoWriter(c.writer, sharedSecret) + if err != nil { + return fmt.Errorf("new crypto writer: %w", err) + } + + c.state = clientStateProxy + + return nil +} + +func (c *clientConn) Read(b []byte) (int, error) { + err := c.handshake() + if err != nil { + return 0, fmt.Errorf("handshake: %w", err) + } + + return c.reader.Read(b) +} + +func (c *clientConn) Write(b []byte) (int, error) { + err := c.handshake() + if err != nil { + return 0, fmt.Errorf("handshake: %w", err) + } + + return c.writer.Write(b) +} + +func (c *clientConn) Close() error { + return c.c.Close() +} + +func (c *clientConn) LocalAddr() net.Addr { + return c.c.LocalAddr() +} + +func (c *clientConn) RemoteAddr() net.Addr { + return c.c.RemoteAddr() +} + +func (c *clientConn) SetDeadline(t time.Time) error { + return c.c.SetDeadline(t) +} + +func (c *clientConn) SetReadDeadline(t time.Time) error { + return c.c.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 +} diff --git a/transport/internet/finalmask/xmc/config.go b/transport/internet/finalmask/xmc/config.go new file mode 100644 index 000000000000..d0f414a7c456 --- /dev/null +++ b/transport/internet/finalmask/xmc/config.go @@ -0,0 +1,27 @@ +package xmc + +import ( + "fmt" + "net" +) + +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) + if err != nil { + return nil, fmt.Errorf("minecraft finalmask: %w", err) + } + + return cc, nil +} + +func (c *Config) WrapConnServer(conn net.Conn) (net.Conn, error) { + cc, err := wrapConnServer(conn, c.Password, c.RsaPrivateKey, c.RsaPublicKey) + if err != nil { + return nil, fmt.Errorf("minecraft finalmask: %w", err) + } + + return cc, nil +} diff --git a/transport/internet/finalmask/xmc/config.pb.go b/transport/internet/finalmask/xmc/config.pb.go new file mode 100644 index 000000000000..f4c983b849c8 --- /dev/null +++ b/transport/internet/finalmask/xmc/config.pb.go @@ -0,0 +1,160 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v6.33.5 +// source: transport/internet/finalmask/xmc/config.proto + +package xmc + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) 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 Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetPassword() string { + if x != nil { + return x.Password + } + 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 + } + return nil +} + +func (x *Config) GetRsaPublicKey() []byte { + if x != nil { + return x.RsaPublicKey + } + return nil +} + +func (x *Config) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +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" + + "\x06Config\x12\x1a\n" + + "\bpassword\x18\x01 \x01(\tR\bpassword\x12\x1c\n" + + "\tusernames\x18\x02 \x03(\tR\tusernames\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" + + ")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 ( + file_transport_internet_finalmask_xmc_config_proto_rawDescOnce sync.Once + file_transport_internet_finalmask_xmc_config_proto_rawDescData []byte +) + +func file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP() []byte { + file_transport_internet_finalmask_xmc_config_proto_rawDescOnce.Do(func() { + file_transport_internet_finalmask_xmc_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_transport_internet_finalmask_xmc_config_proto_rawDesc), len(file_transport_internet_finalmask_xmc_config_proto_rawDesc))) + }) + 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_goTypes = []any{ + (*Config)(nil), // 0: 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 +} + +func init() { file_transport_internet_finalmask_xmc_config_proto_init() } +func file_transport_internet_finalmask_xmc_config_proto_init() { + if File_transport_internet_finalmask_xmc_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + 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, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_transport_internet_finalmask_xmc_config_proto_goTypes, + DependencyIndexes: file_transport_internet_finalmask_xmc_config_proto_depIdxs, + MessageInfos: file_transport_internet_finalmask_xmc_config_proto_msgTypes, + }.Build() + File_transport_internet_finalmask_xmc_config_proto = out.File + file_transport_internet_finalmask_xmc_config_proto_goTypes = nil + file_transport_internet_finalmask_xmc_config_proto_depIdxs = nil +} diff --git a/transport/internet/finalmask/xmc/config.proto b/transport/internet/finalmask/xmc/config.proto new file mode 100644 index 000000000000..25fe29d4b24e --- /dev/null +++ b/transport/internet/finalmask/xmc/config.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package xray.transport.internet.finalmask.xmc; +option csharp_namespace = "Xray.Transport.Internet.Finalmask.XMC"; +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 Config { + string password = 1; + repeated string usernames = 2; + bytes rsa_private_key = 8; + bytes rsa_public_key = 9; + string hostname = 10; +} diff --git a/transport/internet/finalmask/xmc/derivation.go b/transport/internet/finalmask/xmc/derivation.go new file mode 100644 index 000000000000..9dcb6a92a5e4 --- /dev/null +++ b/transport/internet/finalmask/xmc/derivation.go @@ -0,0 +1,103 @@ +package xmc + +import ( + "crypto/rsa" + "crypto/sha256" + "fmt" + "math/big" +) + +type sha256Stream struct { + seed []byte + counter uint64 + buf []byte +} + +func newSHA256Stream(seed []byte) *sha256Stream { + return &sha256Stream{ + seed: seed, + } +} + +func (s *sha256Stream) Read(p []byte) (n int, err error) { + for len(p) > len(s.buf) { + h := sha256.New() + h.Write(s.seed) + h.Write([]byte(fmt.Sprintf("-%d", s.counter))) + s.counter++ + s.buf = append(s.buf, h.Sum(nil)...) + } + n = copy(p, s.buf) + s.buf = s.buf[n:] + return n, nil +} + +func derivePrime(stream *sha256Stream) *big.Int { + pBytes := make([]byte, 64) // 512 bits + _, _ = stream.Read(pBytes) + pBytes[0] |= 0xc0 // ensure it is big enough so p*q is 1024 bits + pBytes[63] |= 0x01 // ensure odd + + p := new(big.Int).SetBytes(pBytes) + for { + if p.ProbablyPrime(20) { + pMinus1 := new(big.Int).Sub(p, big.NewInt(1)) + e := big.NewInt(65537) + gcd := new(big.Int).GCD(nil, nil, pMinus1, e) + if gcd.Cmp(big.NewInt(1)) == 0 { + return p + } + } + p.Add(p, big.NewInt(2)) + } +} + +// DeriveRSAKey derives a 1024-bit RSA private key from a password. +func DeriveRSAKey(password string) (*rsa.PrivateKey, error) { + seed := []byte(password) + + pStream := newSHA256Stream(append(seed, []byte("-p-prime")...)) + qStream := newSHA256Stream(append(seed, []byte("-q-prime")...)) + + p := derivePrime(pStream) + q := derivePrime(qStream) + + // ensure p != q (if they are, let's search q further) + for p.Cmp(q) == 0 { + q.Add(q, big.NewInt(2)) + for { + if q.ProbablyPrime(20) { + qMinus1 := new(big.Int).Sub(q, big.NewInt(1)) + e := big.NewInt(65537) + gcd := new(big.Int).GCD(nil, nil, qMinus1, e) + if gcd.Cmp(big.NewInt(1)) == 0 { + break + } + } + q.Add(q, big.NewInt(2)) + } + } + + n := new(big.Int).Mul(p, q) + pMinus1 := new(big.Int).Sub(p, big.NewInt(1)) + qMinus1 := new(big.Int).Sub(q, big.NewInt(1)) + totient := new(big.Int).Mul(pMinus1, qMinus1) + + e := big.NewInt(65537) + d := new(big.Int).ModInverse(e, totient) + if d == nil { + return nil, fmt.Errorf("failed to compute mod inverse") + } + + priv := &rsa.PrivateKey{ + PublicKey: rsa.PublicKey{ + N: n, + E: 65537, + }, + D: d, + Primes: []*big.Int{p, q}, + } + priv.Precompute() + + return priv, nil +} diff --git a/transport/internet/finalmask/xmc/derivation_test.go b/transport/internet/finalmask/xmc/derivation_test.go new file mode 100644 index 000000000000..b436d46dc5bc --- /dev/null +++ b/transport/internet/finalmask/xmc/derivation_test.go @@ -0,0 +1,58 @@ +package xmc + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "testing" +) + +func TestDeriveRSAKey(t *testing.T) { + password := "my-very-secret-password-12345" + + key1, err := DeriveRSAKey(password) + if err != nil { + t.Fatalf("failed to derive key: %v", err) + } + + err = key1.Validate() + if err != nil { + t.Fatalf("key is not valid RSA key: %v", err) + } + + key2, err := DeriveRSAKey(password) + if err != nil { + t.Fatalf("failed to derive key second time: %v", err) + } + + // Verify determinism + if key1.D.Cmp(key2.D) != 0 || key1.N.Cmp(key2.N) != 0 { + t.Errorf("derived keys are not identical for the same password") + } + + // Verify different passwords yield different keys + keyDifferent, err := DeriveRSAKey(password + "-different") + if err != nil { + t.Fatalf("failed to derive different key: %v", err) + } + + if key1.D.Cmp(keyDifferent.D) == 0 || key1.N.Cmp(keyDifferent.N) == 0 { + t.Errorf("derived keys are identical for different passwords") + } +} + +func TestDeriveRSAKeyGoldenPrivateKey(t *testing.T) { + const password = "deterministic-rsa-key-golden" + const wantPKCS1DERHash = "3a8c4ad56a6fb42dab73c4d5fc3af754460a2db1441edc0970cbc7f4e0798d2f" + + key, err := DeriveRSAKey(password) + if err != nil { + t.Fatalf("failed to derive key: %v", err) + } + + gotHash := sha256.Sum256(x509.MarshalPKCS1PrivateKey(key)) + got := hex.EncodeToString(gotHash[:]) + if got != wantPKCS1DERHash { + t.Fatalf("derived private key changed\nwant sha256: %s\n got sha256: %s", wantPKCS1DERHash, got) + } +} diff --git a/transport/internet/finalmask/xmc/handshake_test.go b/transport/internet/finalmask/xmc/handshake_test.go new file mode 100644 index 000000000000..2e48c309c4d0 --- /dev/null +++ b/transport/internet/finalmask/xmc/handshake_test.go @@ -0,0 +1,158 @@ +package xmc + +import ( + "bytes" + "crypto/x509" + "net" + "sync" + "testing" +) + +func deriveTestRSAKey(t *testing.T, password string) ([]byte, []byte) { + t.Helper() + + key, err := DeriveRSAKey(password) + if err != nil { + t.Fatalf("failed to derive rsa key: %v", err) + } + + publicKey, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatalf("failed to marshal public key: %v", err) + } + + return x509.MarshalPKCS1PrivateKey(key), publicKey +} + +func TestHandshakeSuccess(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer ln.Close() + + password := "super-secure-shared-key-12345" + usernames := []string{"test_user"} + privateKey, publicKey := deriveTestRSAKey(t, password) + + go func() { + rawConn, err := ln.Accept() + if err != nil { + return + } + defer rawConn.Close() + + server, err := wrapConnServer(rawConn, password, privateKey, publicKey) + if err != nil { + t.Errorf("failed to wrap server: %v", err) + return + } + + buf := make([]byte, 1024) + n, err := server.Read(buf) + if err != nil { + t.Errorf("server read error: %v", err) + return + } + + if !bytes.Equal(buf[:n], []byte("hello server")) { + t.Errorf("unexpected payload from client: %s", string(buf[:n])) + return + } + + _, err = server.Write([]byte("hello client")) + if err != nil { + t.Errorf("server write error: %v", err) + return + } + }() + + clientRaw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("failed to dial: %v", err) + } + defer clientRaw.Close() + + client, err := newClientConn(clientRaw, usernames, password, publicKey, "localhost") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + _, err = client.Write([]byte("hello server")) + if err != nil { + t.Fatalf("client write error: %v", err) + } + + buf := make([]byte, 1024) + n, err := client.Read(buf) + if err != nil { + t.Fatalf("client read error: %v", err) + } + + if !bytes.Equal(buf[:n], []byte("hello client")) { + t.Errorf("unexpected payload from server: %s", string(buf[:n])) + } +} + +func TestHandshakePasswordMismatch(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer ln.Close() + + clientPassword := "client-secret-123" + serverPassword := "server-secret-456" + usernames := []string{"test_user"} + serverPrivateKey, serverPublicKey := deriveTestRSAKey(t, serverPassword) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + + rawConn, err := ln.Accept() + if err != nil { + return + } + defer rawConn.Close() + + server, err := wrapConnServer(rawConn, 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) + } + + // When client sends data, handshake happens and should fail + buf := make([]byte, 1024) + _, err = server.Read(buf) + if err == nil { + t.Errorf("expected handshake to fail due to password mismatch, but it succeeded") + } else { + t.Logf("server read failed as expected: %v", err) + } + }() + + clientRaw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("failed to dial: %v", err) + } + defer clientRaw.Close() + + client, err := newClientConn(clientRaw, usernames, 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) + } + + _, _ = client.Write([]byte{0x1, 0x2, 0x3, 0x4}) + + wg.Wait() + + // Check if we lost connection or received error + t.Log("Handshake mismatch tested") +} diff --git a/transport/internet/finalmask/xmc/protocol.go b/transport/internet/finalmask/xmc/protocol.go new file mode 100644 index 000000000000..68a4fb74df29 --- /dev/null +++ b/transport/internet/finalmask/xmc/protocol.go @@ -0,0 +1,335 @@ +// Minecraft protocol +package xmc + +import ( + "bytes" + "fmt" + "io" +) + +type field interface { + readFrom(r io.Reader) error + writeTo(w io.Writer) error +} + +type mcPacket struct { + packetID int + data []byte +} + +func readPacket(b io.Reader) (*mcPacket, error) { + var packetLength Varint + err := packetLength.readFrom(b) + if err != nil { + return nil, fmt.Errorf("read packet length: %w", err) + } + + var packetID Varint + err = packetID.readFrom(b) + if err != nil { + return nil, fmt.Errorf("read packet ID: %w", err) + } + + dataLength := int(packetLength) - varintSize(packetID) + if dataLength < 0 || dataLength > 1024*32 { + return nil, fmt.Errorf("read packet: bad length: %d", dataLength) + } + + data := make([]byte, dataLength) + _, err = io.ReadFull(b, data) + if err != nil { + return nil, fmt.Errorf("read packet data: %w", err) + } + + return &mcPacket{ + packetID: int(packetID), + data: data, + }, nil +} + +func (p *mcPacket) readFields(fields ...field) error { + r := bytes.NewReader(p.data) + + for _, field := range fields { + err := field.readFrom(r) + if err != nil { + return fmt.Errorf("read packet field: %w", err) + } + } + + return nil +} + +type Varint int32 + +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") + } + } + + *v = Varint(value) + + return nil +} + +func (v *Varint) writeTo(w io.Writer) error { + SEGMENT_BITS := byte(0x7F) + CONTINUE_BIT := byte(0x80) + + value := int32(*v) + + for { + currentByte := byte(value & int32(SEGMENT_BITS)) + value >>= 7 + if value != 0 { + currentByte |= CONTINUE_BIT + } + + _, err := w.Write([]byte{currentByte}) + if err != nil { + return fmt.Errorf("write varint: %w", err) + } + + if value == 0 { + break + } + } + + return nil +} + +func varintSize(value Varint) int { + size := 0 + for { + size++ + value >>= 7 + if value == 0 { + break + } + } + return size +} + +type String string + +func (v *String) readFrom(r io.Reader) error { + var length Varint = 0 + + err := length.readFrom(r) + if err != nil { + return fmt.Errorf("read string: %w", err) + } + + if length < 0 || length > 4096 { + return fmt.Errorf("read string: bad length: %d", length) + } + + buf := make([]byte, length) + _, err = io.ReadFull(r, buf) + if err != nil { + return fmt.Errorf("read string: %w", err) + } + + *v = String(string(buf)) + + return nil +} + +func (v *String) writeTo(w io.Writer) error { + strBytes := []byte(*v) + length := Varint(len(strBytes)) + + err := length.writeTo(w) + if err != nil { + return fmt.Errorf("write string: %w", err) + } + + _, err = w.Write(strBytes) + if err != nil { + return fmt.Errorf("write string: %w", err) + } + + return nil +} + +type UnsignedShort uint16 + +func (v *UnsignedShort) readFrom(r io.Reader) error { + var buf [2]byte + _, err := io.ReadFull(r, buf[:]) + if err != nil { + return fmt.Errorf("read unsigned short: %w", err) + } + + *v = UnsignedShort(buf[0])<<8 | UnsignedShort(buf[1]) + + return nil +} + +func (v *UnsignedShort) writeTo(w io.Writer) error { + buf := []byte{byte(*v >> 8), byte(*v & 0xFF)} + _, err := w.Write(buf) + if err != nil { + return fmt.Errorf("write unsigned short: %w", err) + } + return nil +} + +type Long int64 + +func (v *Long) readFrom(r io.Reader) error { + var buf [8]byte + _, err := io.ReadFull(r, buf[:]) + if err != nil { + return fmt.Errorf("read long: %w", err) + } + + *v = Long(buf[0])<<56 | Long(buf[1])<<48 | Long(buf[2])<<40 | Long(buf[3])<<32 | + Long(buf[4])<<24 | Long(buf[5])<<16 | Long(buf[6])<<8 | Long(buf[7]) + + return nil +} + +func (v *Long) writeTo(w io.Writer) error { + buf := []byte{ + byte(*v >> 56), byte((*v >> 48) & 0xFF), byte((*v >> 40) & 0xFF), byte((*v >> 32) & 0xFF), + byte((*v >> 24) & 0xFF), byte((*v >> 16) & 0xFF), byte((*v >> 8) & 0xFF), byte(*v & 0xFF), + } + + _, err := w.Write(buf) + if err != nil { + return fmt.Errorf("write long: %w", err) + } + + return nil +} + +type UUID [16]byte + +func (v *UUID) readFrom(r io.Reader) error { + _, err := io.ReadFull(r, v[:]) + if err != nil { + return fmt.Errorf("read UUID: %w", err) + } + + return nil +} + +func (v *UUID) writeTo(w io.Writer) error { + _, err := w.Write(v[:]) + if err != nil { + return fmt.Errorf("write UUID: %w", err) + } + return nil +} + +type Bytes []byte + +func (v *Bytes) readFrom(r io.Reader) error { + var length Varint + err := length.readFrom(r) + if err != nil { + return fmt.Errorf("read bytes: %w", err) + } + + if length < 0 || length >= 1024 { + return fmt.Errorf("read bytes: invalid size: %d", err) + } + + buf := make([]byte, length) + + _, err = io.ReadFull(r, buf) + if err != nil { + return fmt.Errorf("read bytes: %w", err) + } + + *v = append([]byte(*v), buf...) + + return nil +} + +func (v *Bytes) writeTo(w io.Writer) error { + length := Varint(len(*v)) + err := length.writeTo(w) + if err != nil { + return fmt.Errorf("write bytes length: %w", err) + } + + _, err = w.Write(*v) + if err != nil { + return fmt.Errorf("write bytes: %w", err) + } + + return nil +} + +func readByte(r io.Reader) (byte, error) { + var buf [1]byte + _, err := io.ReadFull(r, buf[:]) + if err != nil { + return 0, fmt.Errorf("read byte: %w", err) + } + + return buf[0], nil +} + +func writePacket(w io.Writer, packetID int, fields ...field) error { + var dataBuf bytes.Buffer + + for _, field := range fields { + err := field.writeTo(&dataBuf) + if err != nil { + return 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) + } + + var packetIDVarint Varint = Varint(packetID) + err = packetIDVarint.writeTo(&buf) + if err != nil { + return fmt.Errorf("write packet ID: %w", err) + } + + buf.Write(dataBuf.Bytes()) + + _, err = w.Write(buf.Bytes()) + if err != nil { + return fmt.Errorf("write packet data: %w", err) + } + + return nil +} + +func writeDisconnectPacket(w io.Writer, reason string) error { + return writePacket(w, 0x00, new(String(reason))) +} diff --git a/transport/internet/finalmask/xmc/server.go b/transport/internet/finalmask/xmc/server.go new file mode 100644 index 000000000000..c79be767d8e7 --- /dev/null +++ b/transport/internet/finalmask/xmc/server.go @@ -0,0 +1,289 @@ +package xmc + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/subtle" + "crypto/x509" + "fmt" + "io" + "net" + "sync" + "time" +) + +// Response by vanilla 26.1.2 server. +var statusResponse = `{"description":"A Minecraft Server","players":{"max":20,"online":0},"version":{"name":"26.1.2","protocol":775},"enforcesSecureChat":true}` + +type serverState int + +var ( + serverStateHandshake serverState = 1 + serverStateProxy serverState = 3 +) + +type serverConn struct { + reader io.Reader + writer io.Writer + c net.Conn + + state serverState + + handshakeLock sync.Mutex + password string + rsaPrivateKey *rsa.PrivateKey + rsaPublicKey []byte +} + +func (c *serverConn) handshake() error { + c.handshakeLock.Lock() + defer c.handshakeLock.Unlock() + + if c.state != serverStateHandshake { + return nil + } + + // handshake timeout + err := c.c.SetDeadline(time.Now().Add(time.Second * 30)) + if err != nil { + return fmt.Errorf("set deadline: %w", err) + } + defer c.c.SetDeadline(time.Time{}) + + var ( + protocolVersion Varint + serverAddress String + serverPort UnsignedShort + nextState Varint + ) + + // handshake packet + + pkt, err := readPacket(c.reader) + if err != nil { + return fmt.Errorf("read handshake packet: %w", err) + } + + if pkt.packetID != 0 { + return fmt.Errorf("bad handshake packet id") + } + + err = pkt.readFields(&protocolVersion, &serverAddress, &serverPort, &nextState) + if err != nil { + return fmt.Errorf("read handshake packet: %w", err) + } + + switch nextState { + case 1: + + // Ping + + for range 2 { + + pkt, err := readPacket(c.reader) + if err != nil { + return fmt.Errorf("read packet: %w", err) + } + + switch pkt.packetID { + case 0: // Status Request + + err = writePacket(c.writer, 0, new(String(statusResponse))) + if err != nil { + return fmt.Errorf("write status response: %w", err) + } + + case 1: // Ping + + var payload Long + err = pkt.readFields(&payload) + if err != nil { + return fmt.Errorf("read ping packet: %w", err) + } + + err = writePacket(c.writer, 1, &payload) + if err != nil { + return fmt.Errorf("write ping response: %w", err) + } + + } + + } + + return fmt.Errorf("ping") + + case 2: + + // Login + + // login start + + pkt, err := readPacket(c.reader) + if err != nil { + return fmt.Errorf("read login start packet: %w", err) + } + + if pkt.packetID != 0 { + return fmt.Errorf("bad login start packet id") + } + + var ( + username String + uuid UUID + ) + + err = pkt.readFields(&username, &uuid) + if err != nil { + return fmt.Errorf("read login start packet: %w", err) + } + + // encrypt request + + var ( + serverId String = String("") + publicKey Bytes = Bytes(c.rsaPublicKey) + verifyToken Bytes = Bytes(make([]byte, 4)) + shouldAuthenticate Varint = Varint(1) + ) + + rand.Read(verifyToken) + + err = writePacket(c.writer, 0x01, &serverId, &publicKey, &verifyToken, &shouldAuthenticate) + if err != nil { + return fmt.Errorf("write encryption request: %w", err) + } + + // encrypt response + + var ( + encryptedSharedSecret Bytes + encryptedVerifyToken Bytes + + sharedSecret []byte + decryptedVerifyToken []byte + ) + + pkt, err = readPacket(c.reader) + if err != nil { + return fmt.Errorf("read encrypt response: %w", err) + } + + if pkt.packetID != 0x01 { + return fmt.Errorf("bad encrypt response packet id") + } + + err = pkt.readFields(&encryptedSharedSecret, &encryptedVerifyToken) + if err != nil { + return fmt.Errorf("read encrypt response: %w", err) + } + + sharedSecret, err = rsa.DecryptPKCS1v15(rand.Reader, c.rsaPrivateKey, encryptedSharedSecret) + if err != nil { + return fmt.Errorf("decrypt shared secret: %w", err) + } + + decryptedVerifyToken, err = rsa.DecryptPKCS1v15(rand.Reader, c.rsaPrivateKey, encryptedVerifyToken) + if err != nil { + return fmt.Errorf("decrypt verify token: %w", err) + } + + if len(decryptedVerifyToken) < 4 || !bytes.Equal(verifyToken, decryptedVerifyToken[:4]) { + return fmt.Errorf("verify token mismatch") + } + + c.reader, err = newCryptoReader(c.reader, sharedSecret) + if err != nil { + return fmt.Errorf("new crypto reader: %w", err) + } + + c.writer, err = newCryptoWriter(c.writer, sharedSecret) + if err != nil { + return fmt.Errorf("new crypto writer: %w", err) + } + + // verify password + receivedPassword := decryptedVerifyToken[4:] + + if subtle.ConstantTimeCompare(receivedPassword, []byte(c.password)) != 1 { + writeDisconnectPacket(c.writer, `{"type":"translatable","translate":"multiplayer.disconnect.authservers_down"}`) + return fmt.Errorf("bad password") + } + + c.state = serverStateProxy + + return nil + + default: + return fmt.Errorf("bad handshake packet: bad next state: %d", nextState) + } +} + +func (c *serverConn) Read(b []byte) (int, error) { + err := c.handshake() + if err != nil { + return 0, fmt.Errorf("handshake: %w", err) + } + + return c.reader.Read(b) +} + +func (c *serverConn) Write(b []byte) (int, error) { + err := c.handshake() + if err != nil { + return 0, fmt.Errorf("handshake: %w", err) + } + + return c.writer.Write(b) +} + +func (c *serverConn) Close() error { + return c.c.Close() +} + +func (c *serverConn) LocalAddr() net.Addr { + return c.c.LocalAddr() +} + +func (c *serverConn) RemoteAddr() net.Addr { + return c.c.RemoteAddr() +} + +func (c *serverConn) SetDeadline(t time.Time) error { + return c.c.SetDeadline(t) +} + +func (c *serverConn) SetReadDeadline(t time.Time) error { + return c.c.SetReadDeadline(t) +} + +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) { + 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) + } + + s := &serverConn{ + reader: bufio.NewReader(c), + writer: c, + c: c, + state: serverStateHandshake, + password: password, + rsaPrivateKey: rsaPrivateKey, + rsaPublicKey: rsaPublicKey, + } + + return s, nil +} diff --git a/transport/internet/finalmask/xmc/stream.go b/transport/internet/finalmask/xmc/stream.go new file mode 100644 index 000000000000..2849a55eaf59 --- /dev/null +++ b/transport/internet/finalmask/xmc/stream.go @@ -0,0 +1,68 @@ +// Minecraft stream cipher +package xmc + +import ( + "crypto/aes" + "crypto/cipher" + "fmt" + "io" +) + +type cryptoStream struct { + stream cipher.Stream + r io.Reader + w io.Writer +} + +func newCryptoReader(r io.Reader, sharedSecret []byte) (*cryptoStream, error) { + blockCipher, err := aes.NewCipher(sharedSecret) + if err != nil { + return nil, fmt.Errorf("new aes cipher: %w", err) + } + + stream := newCFB8Decrypt(blockCipher, sharedSecret) + + return &cryptoStream{stream: stream, r: r}, nil +} + +func (c *cryptoStream) Read(b []byte) (int, error) { + if c.r == nil { + 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.stream.XORKeyStream(b[:n], b[:n]) + + return n, nil +} + +func newCryptoWriter(w io.Writer, sharedSecret []byte) (*cryptoStream, error) { + blockCipher, err := aes.NewCipher(sharedSecret) + if err != nil { + return nil, fmt.Errorf("new aes cipher: %w", err) + } + + stream := newCFB8Encrypt(blockCipher, sharedSecret) + + return &cryptoStream{stream: stream, w: w}, nil +} + +func (c *cryptoStream) Write(b []byte) (int, error) { + if c.w == nil { + panic("write on a read-only crypto stream") + } + + encrypted := make([]byte, len(b)) + c.stream.XORKeyStream(encrypted, b) + + n, err := c.w.Write(encrypted) + if err != nil { + return 0, fmt.Errorf("crypto writer: write: %w", err) + } + + return n, nil +}