From 6a40b5c9ec2b852b2a8083a7979dfb2aaa69966e Mon Sep 17 00:00:00 2001 From: ppeb Date: Mon, 10 Mar 2025 01:44:50 -0500 Subject: [PATCH] Store blob hashes, require on login Blob hashes are now stored in the database under the `hashes` table. They are stored internally as a map of packIDs to a map of versions to a map of regions to the hash. On login, a pack ID, version, and hash are required or the login attempt is rejected. Hashes can be updated using the `/api/hash` endpoint. The schema has been updated accordingly. --- api/hash.go | 86 ++++++++++++++++++++++++++++++ api/main.go | 6 +++ database/hash.go | 136 +++++++++++++++++++++++++++++++++++++++++++++++ gpcm/error.go | 4 ++ gpcm/login.go | 69 ++++++++++++++++++++++++ nas/main.go | 5 ++ schema.sql | 14 ++++- 7 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 api/hash.go create mode 100644 database/hash.go diff --git a/api/hash.go b/api/hash.go new file mode 100644 index 0000000..1eaad67 --- /dev/null +++ b/api/hash.go @@ -0,0 +1,86 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "wwfc/database" + "wwfc/logging" + + "github.com/logrusorgru/aurora/v3" +) + +func HandleHash(w http.ResponseWriter, r *http.Request) { + var success bool + var err string + var statusCode int + + if r.Method == http.MethodPost { + success, err, statusCode = handleHashImpl(r) + } else if r.Method == http.MethodOptions { + statusCode = http.StatusNoContent + w.Header().Set("Access-Control-Allow-Methods", "POST") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + } else { + err = "Incorrect request. POST only." + statusCode = http.StatusMethodNotAllowed + w.Header().Set("Allow", "POST") + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + + var jsonData []byte + + if statusCode != http.StatusNoContent { + w.Header().Set("Content-Type", "application/json") + jsonData, _ = json.Marshal(HashResponse{success, err}) + } + + w.Header().Set("Content-Length", strconv.Itoa(len(jsonData))) + w.WriteHeader(statusCode) + w.Write(jsonData) +} + +type HashRequestSpec struct { + Secret string `json:"secret"` + PackID uint32 `json:"pack_id"` + Version uint32 `json:"version"` + HashNTSCU string `json:"hash_ntscu"` + HashNTSCJ string `json:"hash_ntscj"` + HashNTSCK string `json:"hash_ntsck"` + HashPAL string `json:"hash_pal"` +} + +type HashResponse struct { + Success bool + Error string +} + +func handleHashImpl(r *http.Request) (bool, string, int) { + // TODO: Actual authentication rather than a fixed secret + + body, err := io.ReadAll(r.Body) + if err != nil { + return false, "Unable to read request body", http.StatusBadRequest + } + + var req HashRequestSpec + err = json.Unmarshal(body, &req) + if err != nil { + return false, err.Error(), http.StatusBadRequest + } + + if apiSecret == "" || req.Secret != apiSecret { + return false, "Invalid API secret in request", http.StatusUnauthorized + } + + logging.Notice("API", "Hashes Received, PackID:", aurora.Cyan(req.PackID), "Version:", aurora.Cyan(req.Version), "\nNTSCU:", aurora.Cyan(req.HashNTSCU), "\nNTSCJ:", aurora.Cyan(req.HashNTSCJ), "\nNTSCK:", aurora.Cyan(req.HashNTSCK), "\nPAL:", aurora.Cyan(req.HashPAL)) + + err = database.UpdateHash(pool, ctx, req.PackID, req.Version, req.HashNTSCU, req.HashNTSCJ, req.HashNTSCK, req.HashPAL) + if err != nil { + return false, err.Error(), http.StatusInternalServerError + } + + return true, "", http.StatusOK +} diff --git a/api/main.go b/api/main.go index 33928eb..a890703 100644 --- a/api/main.go +++ b/api/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "wwfc/common" + "wwfc/database" "github.com/jackc/pgx/v4/pgxpool" ) @@ -31,6 +32,11 @@ func StartServer(reload bool) { if err != nil { panic(err) } + + err = database.HashInit(pool, ctx) + if err != nil { + panic(err) + } } func Shutdown() { diff --git a/database/hash.go b/database/hash.go new file mode 100644 index 0000000..d407229 --- /dev/null +++ b/database/hash.go @@ -0,0 +1,136 @@ +package database + +import ( + "context" + "wwfc/logging" + + "github.com/jackc/pgx/v4/pgxpool" + "github.com/logrusorgru/aurora/v3" + "github.com/sasha-s/go-deadlock" +) + +const ( + GetHashes = `SELECT * FROM hashes` + InsertHash = `INSERT + INTO hashes (pack_id, version, hash_ntscu, hash_ntscj, hash_ntsck, hash_pal) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (pack_id, version) + DO UPDATE SET hash_ntscu = $3, hash_ntscj = $4, hash_ntsck = $5, hash_pal = $6` +) + +type Region byte + +const ( + R_NTSCU Region = iota + R_NTSCJ + R_NTSCK + R_PAL +) + +var ( + mutex = deadlock.Mutex{} + hashes = map[uint32]map[uint32]map[Region]string{} +) + +func HashInit(pool *pgxpool.Pool, ctx context.Context) error { + mutex.Lock() + defer mutex.Unlock() + + logging.Info("DB", "Populating hashes from the database") + + rows, err := pool.Query(ctx, GetHashes) + defer rows.Close() + if err != nil { + return err + } + + for rows.Next() { + var packID uint32 + var version uint32 + var hashNTSCU string + var hashNTSCJ string + var hashNTSCK string + var hashPAL string + + err = rows.Scan(&packID, &version, &hashNTSCU, &hashNTSCJ, &hashNTSCK, &hashPAL) + if err != nil { + return err + } + + versions, exists := hashes[packID] + + if !exists { + temp := map[uint32]map[Region]string{} + hashes[packID] = temp + versions = temp + } + + regions, exists := versions[packID] + + if !exists { + temp := map[Region]string{} + versions[packID] = temp + regions = temp + } + + regions[R_NTSCU] = hashNTSCU + regions[R_NTSCJ] = hashNTSCJ + regions[R_NTSCK] = hashNTSCK + regions[R_PAL] = hashPAL + + logging.Info("DB", "Populated hashes for PackID:", aurora.Cyan(packID), "Version:", aurora.Cyan(version), "\nNTSCU:", aurora.Cyan(hashNTSCU), "\nNTSCJ:", aurora.Cyan(hashNTSCJ), "\nNTSCK:", aurora.Cyan(hashNTSCK), "\nPAL:", aurora.Cyan(hashPAL)) + } + + return nil +} + +func UpdateHash(pool *pgxpool.Pool, ctx context.Context, packID uint32, version uint32, hashNTSCU string, hashNTSCJ string, hashNTSCK string, hashPAL string) error { + mutex.Lock() + defer mutex.Unlock() + + versions, exists := hashes[packID] + + if !exists { + temp := map[uint32]map[Region]string{} + hashes[packID] = temp + versions = temp + } + + regions, exists := versions[packID] + + if !exists { + temp := map[Region]string{} + versions[packID] = temp + regions = temp + } + + regions[R_NTSCU] = hashNTSCU + regions[R_NTSCJ] = hashNTSCJ + regions[R_NTSCK] = hashNTSCK + regions[R_PAL] = hashPAL + + _, err := pool.Exec(ctx, InsertHash, packID, version, hashNTSCU, hashNTSCJ, hashNTSCK, hashPAL) + + if err != nil { + logging.Error("DB", "Failed to update hashes for PackID:", aurora.Cyan(packID), "Version:", aurora.Cyan(version), "error:", err.Error()) + } else { + logging.Info("DB", "Successfully updated hashes for PackID:", aurora.Cyan(packID), "Version:", aurora.Cyan(version)) + } + + return err +} + +func ValidateHash(packID uint32, version uint32, region Region, hash string) bool { + mutex.Lock() + defer mutex.Unlock() + + if versions, exists := hashes[packID]; exists { + if regions, exists := versions[version]; exists { + if hash_real, exists := regions[region]; exists { + return hash_real != "" && hash_real == hash + } + } + } + + return false +} diff --git a/gpcm/error.go b/gpcm/error.go index a6c4a32..01c8a99 100644 --- a/gpcm/error.go +++ b/gpcm/error.go @@ -74,6 +74,10 @@ var ( ErrLoginBadPreAuth = MakeGPError(0x010A, "There was an error validating the pre-authentication.", true) ErrLoginLoginTicketInvalid = MakeGPError(0x010B, "The login ticket was unable to be validated.", true) ErrLoginLoginTicketExpired = MakeGPError(0x010C, "The login ticket had expired and could not be used.", true) + ErrLoginBadPackID = MakeGPError(0x010D, "The provided Pack ID was invalid.", true) + ErrLoginBadPackVersion = MakeGPError(0x010E, "The provided Pack Version was invalid.", true) + ErrLoginBadRegion = MakeGPError(0x0110, "The provided Region was invalid for the provided Pack ID.", true) + ErrLoginBadHash = MakeGPError(0x0111, "The hash for the provided Pack ID and Version was invalid.", true) // New user errors ErrNewUser = MakeGPError(0x0200, "There was an error creating a new user.", true) diff --git a/gpcm/login.go b/gpcm/login.go index 57795d5..01995b6 100644 --- a/gpcm/login.go +++ b/gpcm/login.go @@ -196,6 +196,75 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { } } + if g.GameName == "mariokartwii" { + packIDStr, exists := command.OtherValues["pack_id"] + if exists { + logging.Info(g.ModuleName, "pack_id:", aurora.Cyan(packIDStr)) + } else { + logging.Error(g.ModuleName, "Missing pack_id") + g.replyError(ErrLoginBadPackID) + return + } + + packVerStr, exists := command.OtherValues["pack_ver"] + if exists { + logging.Info(g.ModuleName, "pack_ver:", aurora.Cyan(packVerStr)) + } else { + logging.Error(g.ModuleName, "Missing pack_ver") + g.replyError(ErrLoginBadPackVersion) + return + } + + packHashStr, exists := command.OtherValues["pack_hash"] + if exists { + logging.Info(g.ModuleName, "pack_hash:", aurora.Cyan(packHashStr)) + } else { + logging.Error(g.ModuleName, "Missing pack_hash") + g.replyError(ErrLoginBadHash) + return + } + + if len(packHashStr) != 20 { + logging.Error(g.ModuleName, "Invalid pack_hash: Mismatched len,", aurora.Cyan(len(packHashStr))) + g.replyError(ErrLoginBadHash) + return + } + + packID, err := strconv.ParseUint(packIDStr, 10, 32) + if err != nil { + logging.Error(g.ModuleName, "Invalid pack_id:", aurora.Cyan(packID)) + g.replyError(ErrLoginBadPackID) + return + } + + packVer, err := strconv.ParseUint(packIDStr, 10, 32) + if err != nil { + logging.Error(g.ModuleName, "Invalid pack_ver:", aurora.Cyan(packVer)) + g.replyError(ErrLoginBadPackID) + return + } + + var region database.Region + switch gamecd { + case "RMCE": + region = database.R_NTSCU + case "RMCJ": + region = database.R_NTSCJ + case "RMCP": + region = database.R_PAL + default: + logging.Error(g.ModuleName, "Invalid gamecd:", aurora.Cyan(gamecd)) + g.replyError(ErrLoginBadRegion) + return + } + + if !database.ValidateHash(uint32(packID), uint32(packVer), region, packHashStr) { + logging.Error(g.ModuleName, "Invalid pack_hash: Mismatched len,", aurora.Cyan(len(packHashStr))) + g.replyError(ErrLoginBadHash) + return + } + } + g.LoginInfoSet = true expectedUnitCode := common.GetExpectedUnitCode(g.GameName) diff --git a/nas/main.go b/nas/main.go index 8e9c95c..373a8e6 100644 --- a/nas/main.go +++ b/nas/main.go @@ -178,6 +178,11 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { return } + if r.URL.Path == "/api/hash" { + api.HandleHash(w, r) + return + } + logging.Info("NAS", aurora.Yellow(r.Method), aurora.Cyan(r.URL), "via", aurora.Cyan(r.Host), "from", aurora.BrightCyan(r.RemoteAddr)) replyHTTPError(w, 404, "404 Not Found") } diff --git a/schema.sql b/schema.sql index d040cab..9963aad 100644 --- a/schema.sql +++ b/schema.sql @@ -48,7 +48,7 @@ ALTER TABLE ONLY public.users ADD IF NOT EXISTS ban_reason_hidden character varying, ADD IF NOT EXISTS ban_moderator character varying, ADD IF NOT EXISTS ban_tos boolean, - ADD IF NOT EXISTS open_host boolean DEFAULT false; + ADD IF NOT EXISTS open_host boolean DEFAULT false; -- -- Change ng_device_id from bigint to bigint[] @@ -135,6 +135,18 @@ ALTER TABLE ONLY public.users ADD CONSTRAINT users_pkey PRIMARY KEY (profile_id); +CREATE TABLE IF NOT EXISTS public.hashes ( + pack_id integer, + version integer, + hash_ntscu character(40) NOT NULL, + hash_ntscj character(40) NOT NULL, + hash_ntsck character(40) NOT NULL, + hash_pal character(40) NOT NULL, + CONSTRAINT one_hash_per_version_per_pack UNIQUE (pack_id, version) +); + +ALTER TABLE public.hashes OWNER TO wiilink; + -- -- PostgreSQL database dump complete --