From 842d8d9921961e068422a46bc758e7acda01f478 Mon Sep 17 00:00:00 2001 From: MikeIsAStar <99037623+MikeIsAStar@users.noreply.github.com> Date: Wed, 13 Dec 2023 16:15:00 -0500 Subject: [PATCH 1/4] QR2: Do not forward malicious messages to clients --- qr2/message.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/qr2/message.go b/qr2/message.go index 81e4357..3ca3dd6 100644 --- a/qr2/message.go +++ b/qr2/message.go @@ -76,7 +76,13 @@ func SendClientMessage(senderIP string, destSearchID uint64, message []byte) { senderProfileID := binary.LittleEndian.Uint32(message[0x10:0x14]) moduleName = "QR2/MSG:p" + strconv.FormatUint(uint64(senderProfileID), 10) - if (int(message[9]) + 0x14) != len(message) { + dataSize := message[9] + if dataSize > 0x80 { + logging.Error(moduleName, "Received malicious match command packet header") + return + } + + if (int(dataSize) + 0x14) != len(message) { logging.Error(moduleName, "Received invalid match command packet header") return } From ad79cb286a55255177afd5ce4309ad19697903f9 Mon Sep 17 00:00:00 2001 From: mkwcat Date: Thu, 14 Dec 2023 11:24:45 -0500 Subject: [PATCH 2/4] Don't use database for auth tokens --- api/groups.go | 2 +- common/auth_token.go | 158 ++++++++++++++++++++++++++++++++++++++ common/encoding.go | 2 +- common/gamespy_message.go | 4 + database/login.go | 111 +------------------------- database/user.go | 48 ++---------- go.mod | 2 +- gpcm/login.go | 45 ++++++----- gpcm/main.go | 1 + gpcm/profile.go | 2 +- nas/auth.go | 74 ++++++++++++++++-- qr2/group.go | 3 + qr2/logins.go | 4 +- qr2/session.go | 7 +- sake/storage.go | 9 ++- 15 files changed, 287 insertions(+), 185 deletions(-) create mode 100644 common/auth_token.go diff --git a/api/groups.go b/api/groups.go index 1592948..46df856 100644 --- a/api/groups.go +++ b/api/groups.go @@ -31,7 +31,7 @@ func HandleGroups(w http.ResponseWriter, r *http.Request) { filtered := map[string]string{} filtered["pid"] = player["dwc_pid"] - filtered["name"] = player["name"] + filtered["name"] = player["+ingamesn"] if player["gamename"] == "mariokartwii" { filtered["ev"] = player["ev"] diff --git a/common/auth_token.go b/common/auth_token.go new file mode 100644 index 0000000..f2d15e7 --- /dev/null +++ b/common/auth_token.go @@ -0,0 +1,158 @@ +package common + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/binary" + "errors" + "strings" + "time" +) + +func generateRandom(n int) []byte { + key := make([]byte, n) + + read, err := rand.Read(key) + if err != nil { + panic(err) + } + + if read != n { + panic("short rand.Read()") + } + + return key +} + +var ( + authTokenKey = generateRandom(16) + authTokenIV = generateRandom(16) + authTokenMagic = generateRandom(16) + + loginTicketKey = generateRandom(16) + loginTicketIV = generateRandom(16) + loginTicketMagic = generateRandom(4) +) + +func appendString(blob []byte, value string, maxlen int) []byte { + if len([]byte(value)) < maxlen { + blob = append(blob, append([]byte(value), make([]byte, maxlen-len(value))...)...) + } else { + blob = append(blob, []byte(value)[:maxlen]...) + } + + return blob +} + +func MarshalNASAuthToken(gamecd string, userid uint64, gsbrcd string, cfc uint64, region byte, lang byte, ingamesn string) (string, string) { + blob := binary.LittleEndian.AppendUint64([]byte{}, uint64(time.Now().Unix())) + + blob = appendString(blob, gamecd, 4) + + blob = append(blob, binary.LittleEndian.AppendUint64([]byte{}, userid)[:6]...) + + blob = append(blob, byte(min(len([]byte(gsbrcd)), 16))) + blob = appendString(blob, gsbrcd, 16) + + blob = append(blob, binary.LittleEndian.AppendUint64([]byte{}, cfc)[:7]...) + blob = append(blob, region, lang) + + blob = append(blob, byte(min(len([]byte(ingamesn)), 75))) + blob = appendString(blob, ingamesn, 75) + + challenge := RandomString(8) + blob = append(blob, []byte(challenge)...) + blob = append(blob, authTokenMagic...) + + block, err := aes.NewCipher(authTokenKey) + if err != nil { + panic(err) + } + + cipher.NewCBCEncrypter(block, authTokenIV).CryptBlocks(blob, blob) + return "NDS" + Base64DwcEncoding.EncodeToString(blob), challenge +} + +func UnmarshalNASAuthToken(token string) (err error, gamecd string, issuetime time.Time, userid uint64, gsbrcd string, cfc uint64, region byte, lang byte, ingamesn string, challenge string) { + if !strings.HasPrefix(token, "NDS") { + err = errors.New("invalid auth token prefix") + return + } + + blob, err := Base64DwcEncoding.DecodeString(token[3:]) + if err != nil { + return + } + + if len(blob) != 0x90 { + err = errors.New("invalid auth token length") + return + } + + block, err := aes.NewCipher(authTokenKey) + if err != nil { + panic(err) + } + + cipher.NewCBCDecrypter(block, authTokenIV).CryptBlocks(blob, blob) + + if !bytes.Equal(blob[0x80:0x90], authTokenMagic) { + err = errors.New("invalid auth token magic") + return + } + + issuetime = time.Unix(int64(binary.LittleEndian.Uint64(blob[0x0:0x8])), 0) + gamecd = string(blob[0x8:0xC]) + userid = binary.LittleEndian.Uint64(append(bytes.Clone(blob[0xC:0x12]), 0, 0)) + gsbrcd = string(blob[0x13 : 0x13+min(blob[0x12], 16)]) + cfc = binary.LittleEndian.Uint64(append(bytes.Clone(blob[0x23:0x2A]), 0)) + region = blob[0x2A] + lang = blob[0x2B] + ingamesn = string(blob[0x2D : 0x2D+min(blob[0x2C], 75)]) + challenge = string(blob[0x78:0x80]) + return +} + +func MarshalGPCMLoginTicket(profileId uint32) string { + blob := binary.LittleEndian.AppendUint64([]byte{}, uint64(time.Now().Unix())) + blob = binary.LittleEndian.AppendUint32(blob, profileId) + blob = append(blob, loginTicketMagic...) + + block, err := aes.NewCipher(loginTicketKey) + if err != nil { + panic(err) + } + + cipher.NewCBCEncrypter(block, loginTicketIV).CryptBlocks(blob, blob) + return Base64DwcEncoding.EncodeToString(blob) +} + +func UnmarshalGPCMLoginTicket(ticket string) (err error, profileId uint32, issuetime time.Time) { + blob, err := Base64DwcEncoding.DecodeString(ticket) + if err != nil { + return + } + + if len(blob) != 0x10 { + err = errors.New("invalid login ticket length") + return + } + + block, err := aes.NewCipher(loginTicketKey) + if err != nil { + panic(err) + } + + cipher.NewCBCDecrypter(block, loginTicketIV).CryptBlocks(blob, blob) + + if !bytes.Equal(blob[0xC:0x10], loginTicketMagic) { + err = errors.New("invalid login ticket magic") + return + } + + issuetime = time.Unix(int64(binary.LittleEndian.Uint64(blob[0x0:0x8])), 0) + profileId = binary.LittleEndian.Uint32(blob[0x8:0xC]) + return +} diff --git a/common/encoding.go b/common/encoding.go index 7499398..86c2c9b 100644 --- a/common/encoding.go +++ b/common/encoding.go @@ -6,7 +6,7 @@ import ( var Base64DwcEncoding = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-").WithPadding('*') -func Base32Encode(value int64) string { +func Base32Encode(value uint64) string { alpha := "0123456789abcdefghijklmnopqrstuv" encoded := "" diff --git a/common/gamespy_message.go b/common/gamespy_message.go index 2bc1591..ace2118 100644 --- a/common/gamespy_message.go +++ b/common/gamespy_message.go @@ -30,6 +30,10 @@ func ParseGameSpyMessage(msg string) ([]GameSpyCommand, error) { for len(msg) > 0 && string(msg[0]) == `\` { keyEnd := strings.Index(msg[1:], `\`) + 1 + if keyEnd < 2 { + return nil, InvalidGameSpyCommand + } + key := msg[1:keyEnd] value := "" msg = msg[keyEnd+1:] diff --git a/database/login.go b/database/login.go index 8e75355..7693399 100644 --- a/database/login.go +++ b/database/login.go @@ -2,101 +2,13 @@ package database import ( "context" - "crypto/sha512" - "encoding/hex" - "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" "github.com/logrusorgru/aurora/v3" - "os" - "strconv" "wwfc/common" "wwfc/logging" ) -const ( - DoesAuthTokenExist = `SELECT EXISTS(SELECT 1 FROM logins WHERE auth_token = $1)` - DoesNASUserExist = `SELECT EXISTS(SELECT 1 FROM logins WHERE user_id = $1 AND gsbrcd = $2)` - UpdateUserLogin = `UPDATE logins SET auth_token = $1, challenge = $2 WHERE user_id = $3 AND gsbrcd = $4` - InsertUserLogin = `INSERT INTO logins (auth_token, user_id, gsbrcd, challenge) VALUES ($1, $2, $3, $4)` - GetNASUserLogin = `SELECT user_id, gsbrcd FROM logins WHERE auth_token = $1 LIMIT 1` - GetNASChallenge = `SELECT challenge FROM logins WHERE auth_token = $1` -) - -var salt []byte - -// GenerateAuthToken generates and stores the auth token for this user as well as a challenge. -func GenerateAuthToken(pool *pgxpool.Pool, ctx context.Context, userId int64, gsbrcd string) (string, string) { - var userExists bool - err := pool.QueryRow(ctx, DoesNASUserExist, userId, gsbrcd).Scan(&userExists) - if err != nil { - panic(err) - } - - authToken := "NDS" + common.RandomString(80) - for { - // We must make sure that the auth token doesn't exist before attempting to insert it into the database. - var tokenExists bool - err := pool.QueryRow(ctx, DoesAuthTokenExist, authToken).Scan(&tokenExists) - if err != nil { - panic(err) - } - - if !tokenExists { - break - } - - authToken = "NDS" + common.RandomString(80) - } - - challenge := common.RandomString(8) - if userExists { - // UPDATE rather than INSERT - _, err = pool.Exec(ctx, UpdateUserLogin, authToken, challenge, userId, gsbrcd) - if err != nil { - panic(err) - } - } else { - _, err = pool.Exec(ctx, InsertUserLogin, authToken, userId, gsbrcd, challenge) - if err != nil { - panic(err) - } - } - - return authToken, challenge -} - -func GetNASLogin(pool *pgxpool.Pool, ctx context.Context, authToken string) (int64, string) { - var userId int64 - var gsbrcd string - err := pool.QueryRow(ctx, GetNASUserLogin, authToken).Scan(&userId, &gsbrcd) - if err != nil { - if err == pgx.ErrNoRows { - return 0, "" - } else { - panic(err) - } - } - - return userId, gsbrcd -} - -func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, authToken string) (User, bool) { - // Make sure salt is loaded - if salt == nil { - var err error - salt, err = os.ReadFile("salt.bin") - if err != nil { - panic(err) - } - } - - // Query login table with the auth token. - userId, gsbrcd := GetNASLogin(pool, ctx, authToken) - if userId == 0 { - logging.Error("DATABASE", "Invalid auth token:", aurora.Cyan(authToken)) - return User{}, false - } - +func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsbrcd string) (User, bool) { var exists bool err := pool.QueryRow(ctx, DoesUserExist, userId, gsbrcd).Scan(&exists) if err != nil { @@ -104,12 +16,10 @@ func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, authToken string) } uniqueNickname := common.Base32Encode(userId) + gsbrcd - password := sha512.Sum512(append(salt, []byte(gsbrcd)...)) user := User{ UserId: userId, GsbrCode: gsbrcd, - Password: hex.EncodeToString(password[:]), Email: uniqueNickname + "@nds", UniqueNick: uniqueNickname, } @@ -118,30 +28,15 @@ func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, authToken string) // Create the GPCM account user.CreateUser(pool, ctx) - logging.Notice("DATABASE", "Created new GPCM user:", aurora.Cyan(strconv.FormatInt(user.UserId, 10)), aurora.Cyan(user.GsbrCode), "-", aurora.Cyan(strconv.FormatInt(int64(user.ProfileId), 10))) + logging.Notice("DATABASE", "Created new GPCM user:", aurora.Cyan(userId), aurora.Cyan(gsbrcd), "-", aurora.Cyan(user.ProfileId)) } else { err := pool.QueryRow(ctx, GetUserProfileID, userId, gsbrcd).Scan(&user.ProfileId) if err != nil { panic(err) } - logging.Notice("DATABASE", "Log in GPCM user:", aurora.Cyan(strconv.FormatInt(user.UserId, 10)), aurora.Cyan(user.GsbrCode), "-", aurora.Cyan(strconv.FormatInt(int64(user.ProfileId), 10))) + logging.Notice("DATABASE", "Log in GPCM user:", aurora.Cyan(userId), aurora.Cyan(user.GsbrCode), "-", aurora.Cyan(user.ProfileId)) } return user, true } - -func GetChallenge(pool *pgxpool.Pool, ctx context.Context, authToken string) string { - var challenge string - err := pool.QueryRow(ctx, GetNASChallenge, authToken).Scan(&challenge) - if err != nil { - if err == pgx.ErrNoRows { - // Invalid auth token - return "" - } else { - panic(err) - } - } - - return challenge -} diff --git a/database/user.go b/database/user.go index aa132ca..a783863 100644 --- a/database/user.go +++ b/database/user.go @@ -2,17 +2,14 @@ package database import ( "context" - "errors" - "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" "math/rand" - "wwfc/common" ) const ( InsertUser = `INSERT INTO users (user_id, gsbrcd, password, email, unique_nick) VALUES ($1, $2, $3, $4, $5) RETURNING profile_id` - UpdateUserTable = `UPDATE users SET firstname = CASE WHEN $3 THEN $2 ELSE firstname END, lastname = CASE WHEN $5 THEN $4 ELSE lastname END WHERE profile_id = $1 RETURNING user_id, gsbrcd, password, email, unique_nick, firstname, lastname` - GetUser = `SELECT user_id, gsbrcd, password, email, unique_nick, firstname, lastname FROM users WHERE profile_id = $1` + UpdateUserTable = `UPDATE users SET firstname = CASE WHEN $3 THEN $2 ELSE firstname END, lastname = CASE WHEN $5 THEN $4 ELSE lastname END WHERE profile_id = $1 RETURNING user_id, gsbrcd, email, unique_nick, firstname, lastname` + GetUser = `SELECT user_id, gsbrcd, email, unique_nick, firstname, lastname FROM users WHERE profile_id = $1` CreateUserSession = `INSERT INTO sessions (session_key, profile_id, login_ticket) VALUES ($1, $2, $3)` GetTicketSession = `SELECT session_key, profile_id FROM sessions WHERE login_ticket = $1` DoesUserExist = `SELECT EXISTS(SELECT 1 FROM users WHERE user_id = $1 AND gsbrcd = $2)` @@ -25,9 +22,8 @@ const ( type User struct { ProfileId uint32 - UserId int64 + UserId uint64 GsbrCode string - Password string Email string UniqueNick string FirstName string @@ -35,15 +31,15 @@ type User struct { } func (user *User) CreateUser(pool *pgxpool.Pool, ctx context.Context) { - err := pool.QueryRow(ctx, InsertUser, user.UserId, user.GsbrCode, user.Password, user.Email, user.UniqueNick).Scan(&user.ProfileId) + err := pool.QueryRow(ctx, InsertUser, user.UserId, user.GsbrCode, "", user.Email, user.UniqueNick).Scan(&user.ProfileId) if err != nil { panic(err) } } -func GetUniqueUserID() int64 { +func GetUniqueUserID() uint64 { // Not guaranteed unique but doesn't matter in practice if multiple people have the same user ID. - return rand.Int63n(0x80000000000) + return uint64(rand.Int63n(0x80000000000)) } func UpdateProfile(pool *pgxpool.Pool, ctx context.Context, profileId uint32, data map[string]string) User { @@ -52,7 +48,7 @@ func UpdateProfile(pool *pgxpool.Pool, ctx context.Context, profileId uint32, da user := User{} row := pool.QueryRow(ctx, UpdateUserTable, profileId, firstName, firstNameExists, lastName, lastNameExists) - err := row.Scan(&user.UserId, &user.GsbrCode, &user.Password, &user.Email, &user.UniqueNick, &user.FirstName, &user.LastName) + err := row.Scan(&user.UserId, &user.GsbrCode, &user.Email, &user.UniqueNick, &user.FirstName, &user.LastName) if err != nil { panic(err) } @@ -61,38 +57,10 @@ func UpdateProfile(pool *pgxpool.Pool, ctx context.Context, profileId uint32, da return user } -func CreateSession(pool *pgxpool.Pool, ctx context.Context, profileId uint32, loginTicket string) string { - sessionKey := common.RandomString(8) - _, err := pool.Exec(ctx, CreateUserSession, sessionKey, profileId, loginTicket) - if err != nil { - panic(err) - } - - return sessionKey -} - -func GetSession(pool *pgxpool.Pool, ctx context.Context, loginTicket string) (string, uint32) { - var sessionKey string - var profileId uint32 - err := pool.QueryRow(ctx, GetTicketSession, loginTicket).Scan(&sessionKey, &profileId) - if err != nil { - panic(err) - } - - return sessionKey, profileId -} - -func deleteSession(pool *pgxpool.Pool, ctx context.Context, profileId uint32) { - _, err := pool.Exec(ctx, DeleteUserSession, profileId) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - panic(err) - } -} - func GetProfile(pool *pgxpool.Pool, ctx context.Context, profileId uint32) (User, bool) { user := User{} row := pool.QueryRow(ctx, GetUser, profileId) - err := row.Scan(&user.UserId, &user.GsbrCode, &user.Password, &user.Email, &user.UniqueNick, &user.FirstName, &user.LastName) + err := row.Scan(&user.UserId, &user.GsbrCode, &user.Email, &user.UniqueNick, &user.FirstName, &user.LastName) if err != nil { return User{}, false } diff --git a/go.mod b/go.mod index 76ac342..57cf754 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module wwfc -go 1.20 +go 1.21 require ( github.com/jackc/pgx/v4 v4.18.1 diff --git a/gpcm/login.go b/gpcm/login.go index 83ab989..29fcc8b 100644 --- a/gpcm/login.go +++ b/gpcm/login.go @@ -3,12 +3,13 @@ package gpcm import ( "crypto/md5" "crypto/sha1" - "encoding/base64" "encoding/hex" "fmt" + "github.com/logrusorgru/aurora/v3" "math/rand" "strconv" "strings" + "time" "wwfc/common" "wwfc/database" "wwfc/logging" @@ -96,7 +97,7 @@ func verifySignature(authToken string, signature string) bool { logging.Error("GPCM", "Auth token signature failed") return false } - logging.Notice("GPCM", "Auth token signature verified") + logging.Notice("GPCM", "Auth token signature verified; NG ID:", aurora.Cyan(fmt.Sprintf("%08x", ngId))) return true } @@ -124,24 +125,27 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { } signature, exists := command.OtherValues["wwfc_sig"] - if exists { - // TODO: This is still in testing so it's only checked if it exists - if !verifySignature(authToken, signature) { - g.replyError(GPError{ - ErrorCode: ErrLogin.ErrorCode, - ErrorString: "The authentication signature is invalid.", - Fatal: true, - }) - return - } + if !exists || !verifySignature(authToken, signature) { + g.replyError(GPError{ + ErrorCode: ErrLogin.ErrorCode, + ErrorString: "The authentication signature is invalid.", + Fatal: true, + }) + return } - challenge := database.GetChallenge(pool, ctx, authToken) - if challenge == "" { + err, _, issueTime, userId, gsbrcd, cfc, _, _, ingamesn, challenge := common.UnmarshalNASAuthToken(authToken) + if err != nil { g.replyError(ErrLogin) return } + currentTime := time.Now() + if issueTime.Before(currentTime.Add(-10*time.Minute)) || issueTime.After(currentTime) { + g.replyError(ErrLoginLoginTicketExpired) + return + } + response := generateResponse(g.Challenge, challenge, authToken, command.OtherValues["challenge"]) if response != command.OtherValues["response"] { g.replyError(ErrLogin) @@ -151,7 +155,7 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { proof := generateProof(g.Challenge, challenge, command.OtherValues["authtoken"], command.OtherValues["challenge"]) // Perform the login with the database. - user, ok := database.LoginUserToGPCM(pool, ctx, authToken) + user, ok := database.LoginUserToGPCM(pool, ctx, userId, gsbrcd) if !ok { // There was an error logging in to the GP backend. g.replyError(ErrLogin) @@ -175,10 +179,9 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { sessions[g.User.ProfileId] = g mutex.Unlock() - g.LoginTicket = strings.Replace(base64.StdEncoding.EncodeToString([]byte(common.RandomString(16))), "=", "_", -1) - // Now initiate the session - _ = database.CreateSession(pool, ctx, g.User.ProfileId, g.LoginTicket) + g.LoginTicket = common.MarshalGPCMLoginTicket(g.User.ProfileId) g.SessionKey = rand.Int31n(290000000) + 10000000 + g.InGameName = ingamesn g.LoggedIn = true g.ModuleName = "GPCM:" + strconv.FormatInt(int64(g.User.ProfileId), 10) @@ -186,7 +189,7 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { // Notify QR2 of the login // TODO: Get ingamesn and cfc from NAS - qr2.Login(g.User.ProfileId, "", "", g.Conn.RemoteAddr().String()) + qr2.Login(g.User.ProfileId, ingamesn, cfc, g.Conn.RemoteAddr().String()) payload := common.CreateGameSpyMessage(common.GameSpyCommand{ Command: "lc", @@ -194,8 +197,8 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { OtherValues: map[string]string{ "sesskey": strconv.FormatInt(int64(g.SessionKey), 10), "proof": proof, - "userid": strconv.FormatInt(g.User.UserId, 10), - "profileid": strconv.FormatInt(int64(g.User.ProfileId), 10), + "userid": strconv.FormatUint(g.User.UserId, 10), + "profileid": strconv.FormatUint(uint64(g.User.ProfileId), 10), "uniquenick": g.User.UniqueNick, "lt": g.LoginTicket, "id": command.OtherValues["id"], diff --git a/gpcm/main.go b/gpcm/main.go index d2c11a5..21543f4 100644 --- a/gpcm/main.go +++ b/gpcm/main.go @@ -24,6 +24,7 @@ type GameSpySession struct { Challenge string LoginTicket string SessionKey int32 + InGameName string Status string LocString string FriendList []uint32 diff --git a/gpcm/profile.go b/gpcm/profile.go index b3a4d24..1e7df69 100644 --- a/gpcm/profile.go +++ b/gpcm/profile.go @@ -43,7 +43,7 @@ func (g *GameSpySession) getProfile(command common.GameSpyCommand) { OtherValues: map[string]string{ "profileid": command.OtherValues["profileid"], "nick": user.UniqueNick, - "userid": strconv.FormatInt(user.UserId, 10), + "userid": strconv.FormatUint(uint64(user.UserId), 10), "email": user.Email, "sig": common.RandomHexString(32), "uniquenick": user.UniqueNick, diff --git a/nas/auth.go b/nas/auth.go index 1c836ac..a151791 100644 --- a/nas/auth.go +++ b/nas/auth.go @@ -1,6 +1,8 @@ package nas import ( + "encoding/binary" + "encoding/hex" "fmt" "github.com/logrusorgru/aurora/v3" "net/http" @@ -10,6 +12,7 @@ import ( "strconv" "strings" "time" + "unicode/utf16" "wwfc/common" "wwfc/database" "wwfc/logging" @@ -30,7 +33,7 @@ func handleAuthRequest(moduleName string, w http.ResponseWriter, r *http.Request fields := map[string]string{} for key, values := range r.PostForm { if len(values) != 1 { - logging.Warn(moduleName, "Ignoring multiple POST form values:", aurora.Cyan(key).String()+":", aurora.Cyan(values)) + logging.Warn(moduleName, "Ignoring none or multiple POST form values:", aurora.Cyan(key).String()+":", aurora.Cyan(values)) continue } @@ -40,8 +43,20 @@ func handleAuthRequest(moduleName string, w http.ResponseWriter, r *http.Request replyHTTPError(w, 400, "400 Bad Request") return } - logging.Info(moduleName, aurora.Cyan(key).String()+":", aurora.Cyan(string(parsed))) - fields[key] = string(parsed) + + value := string(parsed) + + if key == "ingamesn" { + // Special handling required for the UTF-16 string + var utf16String []uint16 + for i := 0; i < len(parsed)/2; i++ { + utf16String = append(utf16String, binary.BigEndian.Uint16(parsed[i*2:i*2+2])) + } + value = string(utf16.Decode(utf16String)) + } + + logging.Info(moduleName, aurora.Cyan(key).String()+":", aurora.Cyan(value)) + fields[key] = value } reply := map[string]string{} @@ -139,7 +154,7 @@ func acctcreate() map[string]string { "retry": "0", "datetime": getDateTime(), "returncd": "002", - "userid": strconv.FormatInt(database.GetUniqueUserID(), 10), + "userid": strconv.FormatUint(database.GetUniqueUserID(), 10), } } @@ -150,6 +165,13 @@ func login(moduleName string, fields map[string]string) map[string]string { "locator": "gamespy.com", } + gamecd, ok := fields["gamecd"] + if !ok { + logging.Error(moduleName, "No gamecd in form") + param["returncd"] = "103" + return param + } + strUserId, ok := fields["userid"] if !ok { logging.Error(moduleName, "No userid in form") @@ -157,8 +179,8 @@ func login(moduleName string, fields map[string]string) map[string]string { return param } - userId, err := strconv.ParseInt(strUserId, 10, 64) - if err != nil { + userId, err := strconv.ParseUint(strUserId, 10, 64) + if err != nil || userId >= 0x80000000000 { logging.Error(moduleName, "Invalid userid string in form") param["returncd"] = "103" return param @@ -171,7 +193,45 @@ func login(moduleName string, fields map[string]string) map[string]string { return param } - authToken, challenge := database.GenerateAuthToken(pool, ctx, userId, gsbrcd) + cfc, ok := fields["cfc"] + if !ok { + logging.Error(moduleName, "No cfc in form") + param["returncd"] = "103" + return param + } + + cfcInt, err := strconv.ParseUint(cfc, 10, 64) + if err != nil || cfcInt > 9999999999999999 { + logging.Error(moduleName, "Invalid cfc string in form") + param["returncd"] = "103" + return param + } + + region, ok := fields["region"] + if !ok { + region = "ff" + } + + regionByte, err := hex.DecodeString(region) + if err != nil || len(regionByte) != 1 { + logging.Error(moduleName, "Invalid region byte in form") + param["returncd"] = "103" + return param + } + + lang, ok := fields["lang"] + if !ok { + lang = "ff" + } + + langByte, err := hex.DecodeString(lang) + if err != nil || len(langByte) != 1 { + logging.Error(moduleName, "Invalid lang byte in form") + param["returncd"] = "103" + return param + } + + authToken, challenge := common.MarshalNASAuthToken(gamecd, userId, gsbrcd, cfcInt, regionByte[0], langByte[0], fields["ingamesn"]) param["returncd"] = "001" param["challenge"] = challenge diff --git a/qr2/group.go b/qr2/group.go index 2baba55..88b2b5a 100644 --- a/qr2/group.go +++ b/qr2/group.go @@ -187,6 +187,9 @@ func GetGroups(gameName string) []GroupInfo { for k, v := range session.Data { mapData[k] = v } + + mapData["+ingamesn"] = session.Login.InGameName + groupInfo.Players = append(groupInfo.Players, mapData) if mapData["dwc_hoststate"] == "2" && mapData["dwc_suspend"] == "0" { diff --git a/qr2/logins.go b/qr2/logins.go index c3d0af2..cd0fa68 100644 --- a/qr2/logins.go +++ b/qr2/logins.go @@ -3,13 +3,13 @@ package qr2 type LoginInfo struct { ProfileID uint32 InGameName string - ConsoleFriendCode string + ConsoleFriendCode uint64 GPPublicIP string } var logins = map[uint32]LoginInfo{} -func Login(profileID uint32, inGameName, consoleFriendCode, publicIP string) { +func Login(profileID uint32, inGameName string, consoleFriendCode uint64, publicIP string) { mutex.Lock() logins[profileID] = LoginInfo{ ProfileID: profileID, diff --git a/qr2/session.go b/qr2/session.go index 9eb464e..9a63b26 100644 --- a/qr2/session.go +++ b/qr2/session.go @@ -24,6 +24,7 @@ type Session struct { Addr net.Addr Challenge string Authenticated bool + Login LoginInfo LastKeepAlive int64 Endianness byte // Some fields depend on the client's endianness Data map[string]string @@ -155,7 +156,9 @@ func (session *Session) setProfileID(moduleName string, newPID string) bool { // Check if the public IP matches the one used for the GPCM session var gpPublicIP string - if loginInfo, ok := logins[uint32(profileID)]; ok { + var loginInfo LoginInfo + var ok bool + if loginInfo, ok = logins[uint32(profileID)]; ok { gpPublicIP = loginInfo.GPPublicIP } else { logging.Error(moduleName, "Provided dwc_pid is not logged in:", aurora.Cyan(newPID)) @@ -167,6 +170,8 @@ func (session *Session) setProfileID(moduleName string, newPID string) bool { return false } + session.Login = loginInfo + // Constraint: only one session can exist with a profile ID var outdated []uint64 for sessionAddr, otherSession := range sessions { diff --git a/sake/storage.go b/sake/storage.go index 571c253..61a98bc 100644 --- a/sake/storage.go +++ b/sake/storage.go @@ -191,8 +191,13 @@ func getRequestIdentity(moduleName string, request StorageRequestData) (uint32, panic("Invalid secret key") } - // GetSession panics if it can't find the session for the login ticket - _, profileId := database.GetSession(pool, ctx, request.LoginTicket) + logging.Notice(moduleName, request.LoginTicket) + + err, profileId, _ := common.UnmarshalGPCMLoginTicket(request.LoginTicket) + if err != nil { + panic(err) + } + logging.Info(moduleName, "Profile ID:", aurora.BrightCyan(profileId)) logging.Info(moduleName, "Game:", aurora.Cyan(request.GameID), "-", aurora.BrightCyan(gameInfo.Name)) logging.Info(moduleName, "Table ID:", aurora.Cyan(request.TableID)) From adaefd1cbb8ce36ba4f6833fc1f4058e3c2eb125 Mon Sep 17 00:00:00 2001 From: mkwcat Date: Thu, 14 Dec 2023 13:45:09 -0500 Subject: [PATCH 3/4] API: Include friend code in groups --- api/groups.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/groups.go b/api/groups.go index 46df856..54b2f09 100644 --- a/api/groups.go +++ b/api/groups.go @@ -5,6 +5,7 @@ import ( "net/http" "net/url" "strconv" + "wwfc/common" "wwfc/qr2" ) @@ -36,6 +37,10 @@ func HandleGroups(w http.ResponseWriter, r *http.Request) { if player["gamename"] == "mariokartwii" { filtered["ev"] = player["ev"] filtered["eb"] = player["eb"] + pid, err := strconv.ParseUint(player["dwc_pid"], 10, 32) + if err == nil { + filtered["fc"] = common.CalcFriendCodeString(uint32(pid), "RMCJ") + } } group.Players[i] = filtered From 29a2e305d92260ab43db762f9ea5e9571d84705f Mon Sep 17 00:00:00 2001 From: mkwcat Date: Fri, 15 Dec 2023 08:05:36 -0500 Subject: [PATCH 4/4] Allow extra user data in match commands --- common/match_command.go | 80 +++++++++++++++++++++++++++++++---------- gpcm/friend.go | 58 +++++++++++++++++++++++++----- qr2/message.go | 14 +++----- qr2/session.go | 13 ++++++- 4 files changed, 127 insertions(+), 38 deletions(-) diff --git a/common/match_command.go b/common/match_command.go index 979a4b6..aa1f38b 100644 --- a/common/match_command.go +++ b/common/match_command.go @@ -58,6 +58,8 @@ type MatchCommandDataReservation struct { IsFriend bool LocalPlayerCount uint32 ResvCheckValue uint32 + + UserData []byte } type MatchCommandDataResvOK struct { @@ -75,17 +77,20 @@ type MatchCommandDataResvOK struct { ClientCount uint32 ResvCheckValue uint32 - // Only exists in version 3 and 11 + // Version 3 and 11 ProfileIDs []uint32 // Version 11 IsFriend bool - UserData uint32 + + UserData []byte } type MatchCommandDataResvDeny struct { Reason uint32 ReasonString string + + UserData []byte } type MatchCommandDataTellAddr struct { @@ -173,13 +178,18 @@ func DecodeMatchCommand(command byte, buffer []byte, version int) (MatchCommandD return MatchCommandData{}, false } + // Match commands must be 4 byte aligned + if (len(buffer) & 3) != 0 { + return MatchCommandData{}, false + } + switch command { case MatchReservation: - if version == 3 && (len(buffer) != 0x04 && len(buffer) != 0x0C) { + if version == 3 && len(buffer) < 0x0C { break } - if (version == 11 && len(buffer) != 0x14) || (version == 90 && len(buffer) != 0x24) { + if (version == 11 && len(buffer) < 0x14) || (version == 90 && len(buffer) < 0x24) { break } @@ -188,7 +198,7 @@ func DecodeMatchCommand(command byte, buffer []byte, version int) (MatchCommandD break } - if version == 3 && len(buffer) == 0x04 { + if version == 3 && len(buffer) < 0x0C { return MatchCommandData{Reservation: &MatchCommandDataReservation{ MatchType: byte(matchType), HasPublicIP: false, @@ -207,6 +217,7 @@ func DecodeMatchCommand(command byte, buffer []byte, version int) (MatchCommandD HasPublicIP: true, PublicIP: binary.BigEndian.Uint32(buffer[0x04:0x08]), PublicPort: uint16(publicPort), + UserData: buffer[0x0C:], }}, true case 11: @@ -223,6 +234,7 @@ func DecodeMatchCommand(command byte, buffer []byte, version int) (MatchCommandD PublicPort: uint16(publicPort), IsFriend: isFriend, LocalPlayerCount: binary.LittleEndian.Uint32(buffer[0x10:0x14]), + UserData: buffer[0x14:], }}, true case 90: @@ -248,6 +260,7 @@ func DecodeMatchCommand(command byte, buffer []byte, version int) (MatchCommandD IsFriend: isFriend, LocalPlayerCount: binary.LittleEndian.Uint32(buffer[0x1C:0x20]), ResvCheckValue: binary.LittleEndian.Uint32(buffer[0x20:0x24]), + UserData: buffer[0x24:], }}, true } @@ -258,49 +271,50 @@ func DecodeMatchCommand(command byte, buffer []byte, version int) (MatchCommandD } clientCount := binary.LittleEndian.Uint32(buffer[0x00:0x04]) - if version == 3 && (clientCount > 29 || len(buffer) != int(0xC+clientCount*0x4)) { + if version == 3 && (clientCount > 29 || len(buffer) < int(0x0C+clientCount*0x4)) { break } - if version == 11 && (clientCount > 24 || len(buffer) != int(0x20+clientCount*0x4)) { + if version == 11 && (clientCount > 24 || len(buffer) < int(0x20+clientCount*0x4)) { break } var profileIDs []uint32 for i := uint32(0); i < clientCount; i++ { - profileIDs = append(profileIDs, binary.LittleEndian.Uint32(buffer[0x4+i*4:0x4+i*4+4])) + profileIDs = append(profileIDs, binary.LittleEndian.Uint32(buffer[0x04+i*4:0x04+i*4+4])) } - index := 0x4 + clientCount*4 + index := 0x04 + clientCount*4 - publicPort := binary.LittleEndian.Uint32(buffer[index+0x4 : index+0x8]) + publicPort := binary.LittleEndian.Uint32(buffer[index+0x04 : index+0x08]) if publicPort > 0xffff { break } if version == 3 { return MatchCommandData{ResvOK: &MatchCommandDataResvOK{ - PublicIP: binary.BigEndian.Uint32(buffer[index : index+0x4]), + PublicIP: binary.BigEndian.Uint32(buffer[index : index+0x04]), PublicPort: uint16(publicPort), ClientCount: clientCount, ProfileIDs: profileIDs, + UserData: buffer[index+0x8:], }}, true } else if version == 11 { - isFriendValue := binary.LittleEndian.Uint32(buffer[index+0x8 : index+0xC]) + isFriendValue := binary.LittleEndian.Uint32(buffer[index+0x08 : index+0x0C]) if isFriendValue > 1 { break } isFriend := isFriendValue != 0 return MatchCommandData{ResvOK: &MatchCommandDataResvOK{ - MaxPlayers: binary.LittleEndian.Uint32(buffer[0x14:0x18]), - SenderAID: binary.LittleEndian.Uint32(buffer[index+0xC : index+0x10]), - PublicIP: binary.BigEndian.Uint32(buffer[index : index+0x4]), + MaxPlayers: binary.LittleEndian.Uint32(buffer[index+0x14 : index+0x18]), + SenderAID: binary.LittleEndian.Uint32(buffer[index+0x0C : index+0x10]), + PublicIP: binary.BigEndian.Uint32(buffer[index : index+0x04]), PublicPort: uint16(publicPort), - GroupID: binary.LittleEndian.Uint32(buffer[0x10:0x14]), + GroupID: binary.LittleEndian.Uint32(buffer[index+0x10 : index+0x14]), ClientCount: clientCount, ProfileIDs: profileIDs, IsFriend: isFriend, - UserData: binary.LittleEndian.Uint32(buffer[0x18:0x1C]), + UserData: buffer[index+0x18:], }}, true } break @@ -335,6 +349,7 @@ func DecodeMatchCommand(command byte, buffer []byte, version int) (MatchCommandD ReceiverNewAID: binary.LittleEndian.Uint32(buffer[0x28:0x2C]), ClientCount: binary.LittleEndian.Uint32(buffer[0x2C:0x30]), ResvCheckValue: binary.LittleEndian.Uint32(buffer[0x30:0x34]), + UserData: buffer[0x34:], }}, true case MatchResvDeny: @@ -359,6 +374,7 @@ func DecodeMatchCommand(command byte, buffer []byte, version int) (MatchCommandD return MatchCommandData{ResvDeny: &MatchCommandDataResvDeny{ Reason: reason, ReasonString: reasonString, + UserData: buffer[0x4:], }}, true case MatchResvWait: @@ -468,6 +484,12 @@ func EncodeMatchCommand(command byte, data MatchCommandData, version int) ([]byt message = binary.LittleEndian.AppendUint32(message, data.Reservation.LocalPlayerCount) message = binary.LittleEndian.AppendUint32(message, data.Reservation.ResvCheckValue) } + + message = append(message, data.Reservation.UserData...) + if (len(message) & 3) != 0 { + return []byte{}, false + } + return message, true case MatchResvOK: @@ -483,6 +505,11 @@ func EncodeMatchCommand(command byte, data MatchCommandData, version int) ([]byt message = binary.LittleEndian.AppendUint32(message, uint32(data.ResvOK.PublicPort)) if version == 3 { + message = append(message, data.ResvOK.UserData...) + if (len(message) & 3) != 0 { + return []byte{}, false + } + return message, true } @@ -496,7 +523,12 @@ func EncodeMatchCommand(command byte, data MatchCommandData, version int) ([]byt message = binary.LittleEndian.AppendUint32(message, data.ResvOK.SenderAID) message = binary.LittleEndian.AppendUint32(message, data.ResvOK.GroupID) message = binary.LittleEndian.AppendUint32(message, data.ResvOK.MaxPlayers) - message = binary.LittleEndian.AppendUint32(message, data.ResvOK.UserData) + + message = append(message, data.ResvOK.UserData...) + if (len(message) & 3) != 0 { + return []byte{}, false + } + return message, true } @@ -514,10 +546,22 @@ func EncodeMatchCommand(command byte, data MatchCommandData, version int) ([]byt message = binary.LittleEndian.AppendUint32(message, data.ResvOK.ReceiverNewAID) message = binary.LittleEndian.AppendUint32(message, data.ResvOK.ClientCount) message = binary.LittleEndian.AppendUint32(message, data.ResvOK.ResvCheckValue) + + message = append(message, data.ResvOK.UserData...) + if (len(message) & 3) != 0 { + return []byte{}, false + } + return message, true case MatchResvDeny: message := binary.LittleEndian.AppendUint32([]byte{}, data.ResvDeny.Reason) + + message = append(message, data.ResvOK.UserData...) + if (len(message) & 3) != 0 { + return []byte{}, false + } + return message, true case MatchResvWait: diff --git a/gpcm/friend.go b/gpcm/friend.go index c541e7a..0d5f35f 100644 --- a/gpcm/friend.go +++ b/gpcm/friend.go @@ -3,6 +3,7 @@ package gpcm import ( "encoding/binary" "encoding/hex" + "fmt" "github.com/logrusorgru/aurora/v3" "strconv" "strings" @@ -263,6 +264,12 @@ func (g *GameSpySession) bestieMessage(command common.GameSpyCommand) { break } + if len(msgData) > 0x200 || (len(msgData)&3) != 0 { + logging.Error(g.ModuleName, "Invalid length message data; message:", msg) + g.replyError(ErrMessage) + return + } + msgMatchData, ok := common.DecodeMatchCommand(cmd, msgData, version) common.LogMatchCommand(g.ModuleName, strconv.FormatInt(int64(toProfileId), 10), cmd, msgMatchData) if !ok { @@ -272,16 +279,23 @@ func (g *GameSpySession) bestieMessage(command common.GameSpyCommand) { } if cmd == common.MatchReservation { - if common.IPFormatNoPortToInt(g.Conn.RemoteAddr().String()) == int32(msgMatchData.Reservation.PublicIP) { - g.QR2IP = uint64(msgMatchData.Reservation.PublicIP) | (uint64(msgMatchData.Reservation.PublicPort) << 32) + if common.IPFormatNoPortToInt(g.Conn.RemoteAddr().String()) != int32(msgMatchData.Reservation.PublicIP) { + logging.Error(g.ModuleName, "RESERVATION: Public IP mismatch") + g.replyError(ErrMessage) + return } - } else if cmd == common.MatchResvOK { - if common.IPFormatNoPortToInt(g.Conn.RemoteAddr().String()) == int32(msgMatchData.ResvOK.PublicIP) { - g.QR2IP = uint64(msgMatchData.ResvOK.PublicIP) | (uint64(msgMatchData.ResvOK.PublicPort) << 32) - } - } - // TODO: Replace public IP with QR2 search ID + g.QR2IP = uint64(msgMatchData.Reservation.PublicIP) | (uint64(msgMatchData.Reservation.PublicPort) << 32) + + } else if cmd == common.MatchResvOK { + if common.IPFormatNoPortToInt(g.Conn.RemoteAddr().String()) != int32(msgMatchData.ResvOK.PublicIP) { + logging.Error(g.ModuleName, "RESV_OK: Public IP mismatch") + g.replyError(ErrMessage) + return + } + + g.QR2IP = uint64(msgMatchData.ResvOK.PublicIP) | (uint64(msgMatchData.ResvOK.PublicPort) << 32) + } mutex.Lock() defer mutex.Unlock() @@ -300,7 +314,10 @@ func (g *GameSpySession) bestieMessage(command common.GameSpyCommand) { } if cmd == common.MatchReservation { - g.ReservationPID = uint32(toProfileId) + msgMatchData.Reservation.PublicIP = 0 + msgMatchData.Reservation.PublicPort = 0 + msgMatchData.Reservation.LocalIP = 0 + msgMatchData.Reservation.LocalPort = 0 } else if cmd == common.MatchResvOK || cmd == common.MatchResvDeny || cmd == common.MatchResvWait { if toSession.ReservationPID != g.User.ProfileId { logging.Error(g.ModuleName, "Destination", aurora.Cyan(toProfileId), "has no reservation with the sender") @@ -319,9 +336,32 @@ func (g *GameSpySession) bestieMessage(command common.GameSpyCommand) { g.replyError(ErrMessage) return } + + if g.QR2IP&0xffffffff != toSession.QR2IP&0xffffffff { + searchId := qr2.GetSearchID(g.QR2IP) + if searchId == 0 { + logging.Error(g.ModuleName, "Could not get QR2 search ID for IP", aurora.Cyan(fmt.Sprintf("%016x", g.QR2IP))) + g.replyError(ErrMessage) + return + } + + msgMatchData.ResvOK.PublicIP = uint32(searchId & 0xffffffff) + msgMatchData.ResvOK.PublicPort = uint16(searchId >> 32) + } } } + newMsg, ok := common.EncodeMatchCommand(cmd, msgMatchData, version) + if !ok || len(newMsg) > 0x200 { + logging.Error(g.ModuleName, "Failed to encode match command; message:", msg) + g.replyError(ErrMessage) + return + } + + if cmd == common.MatchReservation { + g.ReservationPID = uint32(toProfileId) + } + sendMessageToSession("1", g.User.ProfileId, toSession, msg) } diff --git a/qr2/message.go b/qr2/message.go index 3ca3dd6..4f96600 100644 --- a/qr2/message.go +++ b/qr2/message.go @@ -30,7 +30,7 @@ func SendClientMessage(senderIP string, destSearchID uint64, message []byte) { var receiver *Session senderIPInt, _ := common.IPFormatToInt(senderIP) - useSearchID := destSearchID < (0x400 << 32) + useSearchID := destSearchID < (1 << 24) if useSearchID { receiver = sessionBySearchID[destSearchID] } else { @@ -62,7 +62,7 @@ func SendClientMessage(senderIP string, destSearchID uint64, message []byte) { moduleName = "QR2/MSG:s" + strconv.FormatUint(uint64(natnegID), 10) } else if bytes.Equal(message[:4], []byte{0xbb, 0x49, 0xcc, 0x4d}) || bytes.Equal(message[:4], []byte("SBCM")) { // DWC match command - if len(message) < 0x14 { + if len(message) < 0x14 || len(message) > 0x94 { logging.Error(moduleName, "Received invalid length match command packet") return } @@ -76,13 +76,7 @@ func SendClientMessage(senderIP string, destSearchID uint64, message []byte) { senderProfileID := binary.LittleEndian.Uint32(message[0x10:0x14]) moduleName = "QR2/MSG:p" + strconv.FormatUint(uint64(senderProfileID), 10) - dataSize := message[9] - if dataSize > 0x80 { - logging.Error(moduleName, "Received malicious match command packet header") - return - } - - if (int(dataSize) + 0x14) != len(message) { + if (int(message[9]) + 0x14) != len(message) { logging.Error(moduleName, "Received invalid match command packet header") return } @@ -204,7 +198,7 @@ func SendClientMessage(senderIP string, destSearchID uint64, message []byte) { var matchMessage []byte matchMessage, ok = common.EncodeMatchCommand(message[8], matchData, version) - if !ok { + if !ok || len(matchMessage) > 0x80 { logging.Error(moduleName, "Failed to reencode match command:", aurora.Cyan(printHex(message))) return } diff --git a/qr2/session.go b/qr2/session.go index 9a63b26..0b4b89f 100644 --- a/qr2/session.go +++ b/qr2/session.go @@ -108,7 +108,7 @@ func setSessionData(moduleName string, addr net.Addr, sessionId uint32, payload // Set search ID for { - searchID := uint64(rand.Int63n((0x400<<32)-1) + 1) + searchID := uint64(rand.Int63n((1<<24)-1) + 1) if _, exists := sessionBySearchID[searchID]; !exists { session.SearchID = searchID session.Data["+searchid"] = strconv.FormatUint(searchID, 10) @@ -236,3 +236,14 @@ func GetSessionServers() []map[string]string { return servers } + +func GetSearchID(addr uint64) uint64 { + mutex.Lock() + defer mutex.Unlock() + + if session := sessions[addr]; session != nil { + return session.SearchID + } + + return 0 +}