Merge branch 'feat/nas-refactor'
Some checks are pending
Build CI / build (push) Waiting to run
golangci-lint / lint (push) Waiting to run

This commit is contained in:
Palapeli
2026-04-09 10:07:27 -04:00
10 changed files with 639 additions and 577 deletions

View File

@@ -5,6 +5,7 @@ import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"errors"
"strings"
@@ -29,143 +30,141 @@ func generateRandom(n int) []byte {
var (
authTokenKey = generateRandom(16)
authTokenIV = generateRandom(16)
authTokenMagic = generateRandom(14)
authTokenMagic = generateRandom(8)
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]...)
}
var (
ErrTokenMagic = errors.New("invalid auth token or login ticket magic")
ErrTokenExpired = errors.New("auth token or login ticket expired")
ErrTokenLength = errors.New("invalid auth token or login ticket length")
)
return blob
type NASAuthToken struct {
IssueTime uint64
UserID uint64
ConsoleFriendCode uint64
Region byte
Lang byte
UnitCode byte
GameCode [4]byte
GsbrCode [16]byte
Challenge [8]byte
InGameScreenName [64]byte
Magic [8]byte
}
func MarshalNASAuthToken(gamecd string, userid uint64, gsbrcd string, cfc uint64, region byte, lang byte, ingamesn string, unitcd byte, isLocalhost bool) (string, string) {
blob := binary.LittleEndian.AppendUint64([]byte{}, uint64(time.Now().UTC().Unix()))
var nasAuthTokenSize = (binary.Size(NASAuthToken{}) + aes.BlockSize - 1) & ^(aes.BlockSize - 1)
blob = appendString(blob, gamecd, 4)
func (t NASAuthToken) Marshal() string {
t.IssueTime = uint64(time.Now().UTC().Unix())
copy(t.Magic[:], authTokenMagic)
blob = append(blob, binary.LittleEndian.AppendUint64([]byte{}, userid)[:6]...)
var buf bytes.Buffer
ShouldNotError(binary.Write(&buf, binary.LittleEndian, t))
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, byte(unitcd))
if isLocalhost {
blob = append(blob, 0x01)
} else {
blob = append(blob, 0x00)
}
blob = append(blob, authTokenMagic...)
// Pad to CBC block size
data := append(buf.Bytes(), make([]byte, nasAuthTokenSize-len(buf.Bytes()))...)
block, err := aes.NewCipher(authTokenKey)
if err != nil {
panic(err)
}
ShouldNotError(err)
cipher.NewCBCEncrypter(block, authTokenIV).CryptBlocks(data, data)
cipher.NewCBCEncrypter(block, authTokenIV).CryptBlocks(blob, blob)
return "NDS" + Base64DwcEncoding.EncodeToString(blob), challenge
return "NDS" + Base64DwcEncoding.EncodeToString(data)
}
func UnmarshalNASAuthToken(token string) (gamecd string, issuetime time.Time, userid uint64, gsbrcd string, cfc uint64, region byte, lang byte, ingamesn string, challenge string, unitcd byte, isLocalhost bool, err error) {
if !strings.HasPrefix(token, "NDS") {
err = errors.New("invalid auth token prefix")
return
func (t *NASAuthToken) Unmarshal(data string) error {
if !strings.HasPrefix(data, "NDS") {
return ErrTokenLength
}
blob, err := Base64DwcEncoding.DecodeString(token[3:])
blob, err := Base64DwcEncoding.DecodeString(data[3:])
if err != nil {
return
return err
}
if len(blob) != 0x90 {
err = errors.New("invalid auth token length")
return
if len(blob) != nasAuthTokenSize {
return ErrTokenLength
}
block, err := aes.NewCipher(authTokenKey)
if err != nil {
panic(err)
}
ShouldNotError(err)
cipher.NewCBCDecrypter(block, authTokenIV).CryptBlocks(blob, blob)
if !bytes.Equal(blob[0x90-len(authTokenMagic):0x90], authTokenMagic) {
err = errors.New("invalid auth token magic")
return
reader := bytes.NewReader(blob)
if err := binary.Read(reader, binary.LittleEndian, t); err != nil {
return err
}
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])
unitcd = blob[0x80]
isLocalhost = blob[0x81] == 0x01
return
if !bytes.Equal(t.Magic[:], authTokenMagic) {
return ErrTokenMagic
}
currentTime := time.Now().UTC()
issueTime := time.Unix(int64(t.IssueTime), 0)
if issueTime.Before(currentTime.Add(-10*time.Minute)) || issueTime.After(currentTime) {
return ErrTokenExpired
}
return nil
}
func MarshalGPCMLoginTicket(profileId uint32) string {
blob := binary.LittleEndian.AppendUint64([]byte{}, uint64(time.Now().UTC().Unix()))
blob = binary.LittleEndian.AppendUint32(blob, profileId)
blob = append(blob, loginTicketMagic...)
type GPCMLoginTicket struct {
IssueTime uint64
ProfileID uint32
Magic [4]byte
}
var gpcmLoginTicketSize = (binary.Size(GPCMLoginTicket{}) + aes.BlockSize - 1) & ^(aes.BlockSize - 1)
func (t GPCMLoginTicket) Marshal() string {
t.IssueTime = uint64(time.Now().UTC().Unix())
copy(t.Magic[:], loginTicketMagic)
var buf bytes.Buffer
ShouldNotError(binary.Write(&buf, binary.LittleEndian, t))
// Pad to CBC block size
data := append(buf.Bytes(), make([]byte, gpcmLoginTicketSize-len(buf.Bytes()))...)
block, err := aes.NewCipher(loginTicketKey)
if err != nil {
panic(err)
}
ShouldNotError(err)
cipher.NewCBCEncrypter(block, loginTicketIV).CryptBlocks(data, data)
cipher.NewCBCEncrypter(block, loginTicketIV).CryptBlocks(blob, blob)
return Base64DwcEncoding.EncodeToString(blob)
return base64.StdEncoding.EncodeToString(data)
}
func UnmarshalGPCMLoginTicket(ticket string) (profileId uint32, issuetime time.Time, err error) {
var blob []byte
blob, err = Base64DwcEncoding.DecodeString(ticket)
func (t *GPCMLoginTicket) Unmarshal(ticket string) error {
blob, err := base64.StdEncoding.DecodeString(ticket)
if err != nil {
return
return err
}
if len(blob) != 0x10 {
err = errors.New("invalid login ticket length")
return
}
var block cipher.Block
block, err = aes.NewCipher(loginTicketKey)
if err != nil {
panic(err)
if len(blob) != gpcmLoginTicketSize {
return ErrTokenLength
}
block, err := aes.NewCipher(loginTicketKey)
ShouldNotError(err)
cipher.NewCBCDecrypter(block, loginTicketIV).CryptBlocks(blob, blob)
if !bytes.Equal(blob[0xC:0x10], loginTicketMagic) {
err = errors.New("invalid login ticket magic")
return
reader := bytes.NewReader(blob)
if err := binary.Read(reader, binary.LittleEndian, t); err != nil {
return err
}
issuetime = time.Unix(int64(binary.LittleEndian.Uint64(blob[0x0:0x8])), 0)
profileId = binary.LittleEndian.Uint32(blob[0x8:0xC])
return
if !bytes.Equal(t.Magic[:], loginTicketMagic) {
return ErrTokenMagic
}
currentTime := time.Now().UTC()
issueTime := time.Unix(int64(t.IssueTime), 0)
if issueTime.Before(currentTime.Add(-48*time.Hour)) || issueTime.After(currentTime) {
return ErrTokenExpired
}
return nil
}

View File

@@ -2,6 +2,8 @@ package common
import (
"encoding/base64"
"encoding/binary"
"unicode/utf16"
)
var (
@@ -40,3 +42,34 @@ func reverse(s string) string {
return string(rns)
}
func UTF16Encode(s string, order binary.ByteOrder) []byte {
encoded := utf16.Encode([]rune(s))
buf := make([]byte, len(encoded)*2)
for i, v := range encoded {
order.PutUint16(buf[i*2:], v)
}
return buf
}
func UTF16Decode(u []byte, order binary.ByteOrder) string {
decoded := make([]uint16, len(u)/2)
for i := range decoded {
v := order.Uint16(u[i*2:])
if v == 0 {
decoded = decoded[:i]
break
}
decoded[i] = v
}
return string(utf16.Decode(decoded))
}
func NullTerminatedString(u []byte) string {
for i, b := range u {
if b == 0 {
return string(u[:i])
}
}
return string(u)
}

View File

@@ -3,7 +3,6 @@ package gamestats
import (
"math/rand"
"strconv"
"time"
"wwfc/common"
"wwfc/gpcm"
"wwfc/logging"
@@ -62,21 +61,15 @@ func (g *GameStatsSession) authp(command common.GameSpyCommand) {
return
}
_, issueTime, userId, gsbrcd, _, _, _, _, _, _, _, err := common.UnmarshalNASAuthToken(authToken)
authTokenObj := common.NASAuthToken{}
err := authTokenObj.Unmarshal(authToken)
if err != nil {
logging.Error(g.ModuleName, "Error unmarshalling authtoken:", err.Error())
g.Write(errorCmd)
return
}
currentTime := time.Now().UTC()
if issueTime.Before(currentTime.Add(-10*time.Minute)) || issueTime.After(currentTime) {
logging.Error(g.ModuleName, "Authtoken has expired")
g.Write(errorCmd)
return
}
g.User, err = db.LoginUserToGameStats(userId, gsbrcd)
g.User, err = db.LoginUserToGameStats(authTokenObj.UserID, common.NullTerminatedString(authTokenObj.GsbrCode[:]))
if err != nil {
logging.Error(g.ModuleName, "Error logging in user:", err.Error())
g.Write(errorCmd)

View File

@@ -189,26 +189,32 @@ func (g *GameSpySession) login(command common.GameSpyCommand) {
return
}
gamecd, issueTime, userId, gsbrcd, cfc, region, lang, ingamesn, challenge, unitcd, isLocalhost, err := common.UnmarshalNASAuthToken(authToken)
authTokenObj := common.NASAuthToken{}
err := authTokenObj.Unmarshal(authToken)
if err != nil {
logging.Error(g.ModuleName, "Failed to unmarshal auth token:", err)
if err == common.ErrTokenExpired {
g.replyError(ErrLoginLoginTicketExpired)
return
}
g.replyError(ErrLogin)
return
}
currentTime := time.Now().UTC()
if issueTime.Before(currentTime.Add(-10*time.Minute)) || issueTime.After(currentTime) {
g.replyError(ErrLoginLoginTicketExpired)
return
}
g.GameName = command.OtherValues["gamename"]
logging.Info(g.ModuleName, "Game name:", aurora.Cyan(g.GameName))
g.GameCode = gamecd
g.Region = region
g.Language = lang
g.ConsoleFriendCode = cfc
g.InGameName = ingamesn
g.UnitCode = unitcd
g.GameCode = common.NullTerminatedString(authTokenObj.GameCode[:])
g.Region = authTokenObj.Region
g.Language = authTokenObj.Lang
g.ConsoleFriendCode = authTokenObj.ConsoleFriendCode
g.UnitCode = authTokenObj.UnitCode
var endianness binary.ByteOrder = binary.LittleEndian
if g.UnitCode == UnitCodeWii {
endianness = binary.BigEndian
}
g.InGameName = common.UTF16Decode(authTokenObj.InGameScreenName[:], endianness)
_, payloadVerExists := command.OtherValues["wl:ver"]
_, signatureExists := command.OtherValues["wl:sig"]
@@ -229,18 +235,18 @@ func (g *GameSpySession) login(command common.GameSpyCommand) {
logging.Event(
"received_login_info",
map[string]any{
"user_id": userId,
"user_id": authTokenObj.UserID,
"game_name": g.GameName,
"wii_number": cfc,
"in_game_name": ingamesn,
"unit_code": unitcd,
"wii_number": g.ConsoleFriendCode,
"in_game_name": g.InGameName,
"unit_code": g.UnitCode,
"ip_address": g.RemoteAddr,
},
)
expectedUnitCode := common.GetExpectedUnitCode(g.GameName)
if (g.UnitCode != UnitCodeDS && g.UnitCode != UnitCodeWii) || (g.UnitCode != expectedUnitCode && expectedUnitCode != UnitCodeDSAndWii) {
logging.Error(g.ModuleName, "Incorrect unit code specified:", aurora.Cyan(unitcd))
logging.Error(g.ModuleName, "Incorrect unit code specified:", aurora.Cyan(g.UnitCode))
g.replyError(ErrLogin)
return
}
@@ -253,7 +259,7 @@ func (g *GameSpySession) login(command common.GameSpyCommand) {
deviceAuth = true
case UnitCodeWii:
if isLocalhost && !payloadVerExists && !signatureExists {
if !payloadVerExists && !signatureExists {
// Players using the DNS, need patching using a QR2 exploit
if !common.DoesGameNeedExploit(g.GameName) {
logging.Error(g.ModuleName, "Using DNS for incompatible game:", aurora.Cyan(g.GameName))
@@ -276,18 +282,20 @@ func (g *GameSpySession) login(command common.GameSpyCommand) {
}
default:
logging.Error(g.ModuleName, "Invalid unit code specified:", aurora.Cyan(unitcd))
logging.Error(g.ModuleName, "Invalid unit code specified:", aurora.Cyan(g.UnitCode))
g.replyError(ErrLogin)
return
}
response := generateResponse(g.Challenge, challenge, authToken, command.OtherValues["challenge"])
nasChallenge := common.NullTerminatedString(authTokenObj.Challenge[:])
response := generateResponse(g.Challenge, nasChallenge, authToken, command.OtherValues["challenge"])
if response != command.OtherValues["response"] {
g.replyError(ErrLogin)
return
}
proof := generateProof(g.Challenge, challenge, command.OtherValues["authtoken"], command.OtherValues["challenge"])
proof := generateProof(g.Challenge, nasChallenge, command.OtherValues["authtoken"], command.OtherValues["challenge"])
cmdProfileId := uint32(0)
if cmdProfileIdStr, exists := command.OtherValues["profileid"]; exists {
@@ -305,7 +313,7 @@ func (g *GameSpySession) login(command common.GameSpyCommand) {
cmdProfileId = uint32(cmdProfileId2)
}
if !g.performLoginWithDatabase(userId, gsbrcd, cmdProfileId, defaultKey, deviceId, deviceAuth) {
if !g.performLoginWithDatabase(authTokenObj.UserID, common.NullTerminatedString(authTokenObj.GsbrCode[:]), cmdProfileId, defaultKey, deviceId, deviceAuth) {
return
}
@@ -353,16 +361,17 @@ func (g *GameSpySession) login(command common.GameSpyCommand) {
mutex.Unlock()
g.AuthToken = authToken
g.LoginTicket = common.MarshalGPCMLoginTicket(g.User.ProfileId)
g.LoginTicket = common.GPCMLoginTicket{ProfileID: g.User.ProfileId}.Marshal()
g.SessionKey = rand.Int31n(290000000) + 10000000
g.DeviceAuthenticated = deviceAuth
g.LoggedIn = true
g.ModuleName = "GPCM:" + strconv.FormatInt(int64(g.User.ProfileId), 10)
g.ModuleName += "/" + common.CalcFriendCodeString(g.User.ProfileId, g.User.GsbrCode[:4])
// Notify QR2 of the login
qr2.Login(g.User.ProfileId, gamecd, ingamesn, cfc, g.User.GsbrCode[:4], g.RemoteAddr, g.NeedsExploit, g.DeviceAuthenticated, g.User.Restricted)
qr2.Login(g.User.ProfileId, g.GameCode, g.InGameName, g.ConsoleFriendCode, g.User.GsbrCode[:4], g.RemoteAddr, g.NeedsExploit, g.DeviceAuthenticated, g.User.Restricted)
replyUserId := g.User.UserId
if g.UnitCode == UnitCodeDS {

244
nas/account.go Normal file
View File

@@ -0,0 +1,244 @@
package nas
import (
"encoding/binary"
"encoding/hex"
"net/http"
"strconv"
"strings"
"wwfc/common"
"wwfc/database"
"wwfc/logging"
"github.com/logrusorgru/aurora/v3"
)
var accountActions = map[string]func(moduleName string, fields map[string][]byte) map[string]string{
"acctcreate": acctcreate,
"login": login,
"svcloc": svcloc,
}
func handleAuthAccountEndpoint(w http.ResponseWriter, r *http.Request) {
moduleName := getModuleName(r)
fields, err := parseAuthRequest(r)
if err != nil {
replyHTTPError(w, 400, "400 Bad Request")
return
}
action := string(fields["action"])
if action == "" {
logging.Error(moduleName, "No action in form")
replyHTTPError(w, 400, "400 Bad Request")
return
}
if actionFunc, exists := accountActions[action]; exists {
reply := actionFunc(moduleName, fields)
writeAuthResponse(w, reply)
return
}
logging.Error(moduleName, "Unknown action:", aurora.Cyan(action))
replyHTTPError(w, 400, "400 Bad Request")
}
func acctcreate(moduleName string, fields map[string][]byte) map[string]string {
return map[string]string{
"retry": "0",
"datetime": getDateTime(),
"returncd": "002",
"userid": strconv.FormatUint(database.GetUniqueUserID(), 10),
}
}
func login(moduleName string, fields map[string][]byte) map[string]string {
param := map[string]string{
"retry": "0",
"datetime": getDateTime(),
"locator": "gamespy.com",
}
token := common.NASAuthToken{}
gamecd, ok := fields["gamecd"]
if !ok {
logging.Error(moduleName, "No gamecd in form")
param["returncd"] = "103"
return param
}
copy(token.GameCode[:], gamecd)
strUserId, ok := fields["userid"]
if !ok {
logging.Error(moduleName, "No userid in form")
param["returncd"] = "103"
return param
}
var err error
token.UserID, err = strconv.ParseUint(string(strUserId), 10, 64)
if err != nil || token.UserID >= 0x80000000000 {
logging.Error(moduleName, "Invalid userid string in form")
param["returncd"] = "103"
return param
}
gsbrcd, ok := fields["gsbrcd"]
if !ok {
logging.Error(moduleName, "No gsbrcd in form")
param["returncd"] = "103"
return param
}
if (len(gsbrcd) < 4 && len(gsbrcd) != 0) || strings.ContainsRune(string(gsbrcd), 0) {
logging.Error(moduleName, "Invalid gsbrcd string in form")
param["returncd"] = "103"
return param
}
// Some games like Fortune Street make login requests without a gsbr code, so we temporarily fake one
if len(gsbrcd) == 0 {
if len(gamecd) < 4 {
logging.Error(moduleName, "Invalid gamecd string in form")
param["returncd"] = "103"
return param
}
gsbrcd = append(gamecd[:3], 'J')
}
copy(token.GsbrCode[:], gsbrcd)
lang, ok := fields["lang"]
if !ok {
lang = []byte("ff")
}
langByte, err := hex.DecodeString(string(lang))
if err != nil || len(langByte) != 1 {
logging.Error(moduleName, "Invalid lang byte in form")
param["returncd"] = "103"
return param
}
token.Lang = langByte[0]
unitcd, ok := fields["unitcd"]
if !ok {
logging.Error(moduleName, "No unitcd in form")
param["returncd"] = "103"
return param
}
isWii := len(unitcd) > 1 || unitcd[0] != '0'
var endianness binary.ByteOrder
switch isWii {
case false:
token.UnitCode = 0
endianness = binary.LittleEndian
case true:
token.UnitCode = 1
endianness = binary.BigEndian
}
hasProfaneName := false
ingamesn, hasIngamesn := fields["ingamesn"]
ingamesnStr := ""
if hasIngamesn {
ingamesnStr = common.UTF16Decode(ingamesn, endianness)
if hasProfaneName, _ = IsBadWord(ingamesnStr); hasProfaneName {
logging.Info(moduleName, "Provided in-game screen name has a profane word:", aurora.Red(ingamesnStr).String())
// Continue with different return code
}
}
switch isWii {
case false:
devname, ok := fields["devname"]
if !ok {
logging.Error(moduleName, "No devname in form")
param["returncd"] = "103"
return param
}
// Only later DS games send ingamesn
if !hasIngamesn {
ingamesn = devname
}
logging.Notice(moduleName, "Login (DS)", aurora.Cyan(token.UserID), aurora.Cyan(gsbrcd), "devname:", aurora.Cyan(devname), "name:", aurora.Cyan(ingamesnStr))
case true:
cfc, ok := fields["cfc"]
if !ok {
logging.Error(moduleName, "No cfc in form")
param["returncd"] = "103"
return param
}
token.ConsoleFriendCode, err = strconv.ParseUint(string(cfc), 10, 64)
if err != nil || token.ConsoleFriendCode > 9999999999999999 {
logging.Error(moduleName, "Invalid cfc string in form")
param["returncd"] = "103"
return param
}
region, ok := fields["region"]
if !ok {
region = []byte("ff")
}
regionByte, err := hex.DecodeString(string(region))
if err != nil || len(regionByte) != 1 {
logging.Error(moduleName, "Invalid region byte in form")
param["returncd"] = "103"
return param
}
token.Region = regionByte[0]
logging.Notice(moduleName, "Login (Wii)", aurora.Cyan(token.UserID), aurora.Cyan(string(gsbrcd)), "name:", aurora.Cyan(ingamesnStr))
}
challenge := common.RandomString(8)
copy(token.Challenge[:], []byte(challenge))
copy(token.InGameScreenName[:], ingamesn)
if hasProfaneName {
param["returncd"] = "040"
} else {
param["returncd"] = "001"
}
param["challenge"] = challenge
param["token"] = token.Marshal()
return param
}
func svcloc(moduleName string, fields map[string][]byte) map[string]string {
param := map[string]string{
"retry": "0",
"datetime": getDateTime(),
"returncd": "007",
"statusdata": "Y",
}
authToken := "NDS/SVCLOC/TOKEN"
switch string(fields["svc"]) {
default:
param["servicetoken"] = authToken
param["svchost"] = "n/a"
case "9000":
param["token"] = authToken
param["svchost"] = "dls1.nintendowifi.net"
case "9001":
param["servicetoken"] = authToken
param["svchost"] = "dls1.nintendowifi.net"
}
return param
}

View File

@@ -2,18 +2,14 @@ package nas
import (
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf16"
"wwfc/common"
"wwfc/database"
"wwfc/logging"
"github.com/logrusorgru/aurora/v3"
@@ -23,470 +19,83 @@ var (
dlcDir = "./dlc"
)
func handleAuthRequest(w http.ResponseWriter, r *http.Request) {
func parseAuthRequest(r *http.Request) (map[string][]byte, error) {
moduleName := getModuleName(r)
err := r.ParseForm()
if err != nil {
logging.Error(moduleName, "Failed to parse form")
replyHTTPError(w, 400, "400 Bad Request")
return
return nil, errors.New("failed to parse form")
}
// Need to know this here to determine UTF-16 endianness (LE for DS, BE for Wii)
// unitcd 0 = DS, 1 = Wii
unitcd := "1"
isWii := false
if unitcdValues, ok := r.PostForm["unitcd"]; ok {
unitcdDecoded, err := common.Base64DwcEncoding.DecodeString(unitcdValues[0])
if err != nil {
logging.Error(moduleName, "Invalid unitcd string in form")
replyHTTPError(w, 400, "400 Bad Request")
return
return nil, errors.New("invalid unitcd string in form")
}
unitcd = string(unitcdDecoded)
isWii = len(unitcdDecoded) != 1 || unitcdDecoded[0] != '0'
}
fields := map[string]string{}
var endianness binary.ByteOrder = binary.LittleEndian
if isWii {
endianness = binary.BigEndian
}
fields := map[string][]byte{}
for key, values := range r.PostForm {
if len(values) != 1 {
logging.Warn(moduleName, "Ignoring none or multiple POST form values:", aurora.Cyan(key).String()+":", aurora.Cyan(values))
continue
}
var value string
if !strings.HasPrefix(key, "_") {
parsed, err := common.Base64DwcEncoding.DecodeString(values[0])
if err != nil {
logging.Error(moduleName, "Invalid POST form value:", aurora.Cyan(key).String()+":", aurora.Cyan(values[0]))
replyHTTPError(w, 400, "400 Bad Request")
return
}
if key == "ingamesn" || key == "devname" || key == "words" {
// Special handling required for the UTF-16 string
var utf16String []uint16
if unitcd == "0" {
for i := 0; i < len(parsed)/2; i++ {
utf16String = append(utf16String, binary.LittleEndian.Uint16(parsed[i*2:i*2+2]))
}
} else {
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))
} else {
value = string(parsed)
}
} else {
// Values unique to CTGP/the Wiimmfi payload, for compatibility reasons. Some of these are not base64 encoded.
value = values[0]
if strings.HasPrefix(key, "_") {
// Values unique to CTGP/the Wiimmfi payload. Ignored for compatibility reasons.
continue
}
logging.Info(moduleName, aurora.Cyan(key).String()+":", aurora.Cyan(value))
fields[key] = value
parsed, err := common.Base64DwcEncoding.DecodeString(values[0])
if err != nil {
logging.Error(moduleName, "Invalid POST form value:", aurora.Cyan(key).String()+":", aurora.Cyan(values[0]))
return nil, errors.New("invalid POST form value: " + key)
}
fields[key] = parsed
reported := string(parsed)
if key == "ingamesn" || key == "devname" || key == "words" {
// Special handling required for reporting the UTF-16 strings
reported = common.UTF16Decode(parsed, endianness)
}
logging.Info(moduleName, aurora.Cyan(key).String()+":", aurora.Cyan(reported))
}
reply := map[string]string{}
return fields, nil
}
func writeAuthResponse(w http.ResponseWriter, reply map[string]string) {
var response []byte
switch r.URL.String() {
case "/ac":
action, ok := fields["action"]
if !ok || action == "" {
logging.Error(moduleName, "No action in form")
replyHTTPError(w, 400, "400 Bad Request")
return
}
switch strings.ToLower(action) {
case "acctcreate":
reply = acctcreate()
case "login":
isLocalhost := strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") || strings.HasPrefix(r.RemoteAddr, "[::1]:")
reply = login(moduleName, fields, isLocalhost)
case "svcloc":
reply = svcloc(fields)
default:
logging.Error(moduleName, "Unknown action:", aurora.Cyan(action))
reply = map[string]string{
"retry": "0",
"returncd": "109",
}
}
case "/pr":
words, ok := fields["words"]
if words == "" || !ok {
logging.Error(moduleName, "No words in form")
replyHTTPError(w, 400, "400 Bad Request")
return
}
reply = handleProfanity(r.PostForm, unitcd)
case "/download":
action, ok := fields["action"]
if !ok || action == "" {
logging.Error(moduleName, "No action in form")
replyHTTPError(w, 400, "400 Bad Request")
return
}
rhgamecd, ok := fields["rhgamecd"]
if !ok || !isValidRhgamecd(rhgamecd) {
logging.Error(moduleName, "Missing or invalid rhgamecd")
replyHTTPError(w, 400, "400 Bad Request")
return
}
switch strings.ToLower(action) {
case "count":
response = []byte(dlsCount(fields))
default:
logging.Error(moduleName, "Unknown action:", aurora.Cyan(action))
reply = map[string]string{
"retry": "0",
"returncd": "109",
}
}
w.Header().Set("X-DLS-Host", "http://127.0.0.1/")
}
if len(response) == 0 {
param := url.Values{}
for key, value := range reply {
param.Set(key, common.Base64DwcEncoding.EncodeToString([]byte(value)))
}
response = []byte(param.Encode())
response = []byte(strings.ReplaceAll(string(response), "%2A", "*"))
param := url.Values{}
for key, value := range reply {
param.Set(key, common.Base64DwcEncoding.EncodeToString([]byte(value)))
}
response = []byte(param.Encode())
response = []byte(strings.ReplaceAll(string(response), "%2A", "*"))
// DWC treats the response like a null terminated string
response = append(response, 0x00)
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Length", strconv.Itoa(len(response)))
_, err = w.Write(response)
_, err := w.Write(response)
if err != nil {
logging.Error("NAS", "Error writing response:", err)
}
}
func acctcreate() map[string]string {
return map[string]string{
"retry": "0",
"datetime": getDateTime(),
"returncd": "002",
"userid": strconv.FormatUint(database.GetUniqueUserID(), 10),
}
}
func login(moduleName string, fields map[string]string, isLocalhost bool) map[string]string {
param := map[string]string{
"retry": "0",
"datetime": getDateTime(),
"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")
param["returncd"] = "103"
return param
}
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
}
gsbrcd, ok := fields["gsbrcd"]
if !ok {
logging.Error(moduleName, "No gsbrcd in form")
param["returncd"] = "103"
return param
}
if (len(gsbrcd) < 4 && len(gsbrcd) != 0) || strings.ContainsRune(gsbrcd, 0) {
logging.Error(moduleName, "Invalid gsbrcd string in form")
param["returncd"] = "103"
return param
}
// Some games like Fortune Street make login requests without a gsbr code, so we temporarily fake one
if len(gsbrcd) == 0 {
if len(gamecd) < 4 {
logging.Error(moduleName, "Invalid gamecd string in form")
param["returncd"] = "103"
return param
}
gsbrcd = gamecd[:3] + "J"
}
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
}
unitcd, ok := fields["unitcd"]
if !ok {
logging.Error(moduleName, "No unitcd in form")
param["returncd"] = "103"
return param
}
unitcdInt, err := strconv.ParseUint(unitcd, 10, 64)
if err != nil || unitcdInt > 1 {
logging.Error(moduleName, "Invalid unitcd string in form")
param["returncd"] = "103"
return param
}
hasProfaneName := false
ingamesn, ok := fields["ingamesn"]
if ok {
if hasProfaneName, _ = IsBadWord(ingamesn); hasProfaneName {
logging.Info(moduleName, aurora.Cyan(strconv.FormatUint(userId, 10)), "has a profane name ("+aurora.Red(ingamesn).String()+")")
}
}
var authToken, challenge string
switch unitcdInt {
// ds
case 0:
devname, ok := fields["devname"]
if !ok {
logging.Error(moduleName, "No devname in form")
param["returncd"] = "103"
return param
}
// Only later DS games send this
ingamesn, ok := fields["ingamesn"]
if ok {
authToken, challenge = common.MarshalNASAuthToken(gamecd, userId, gsbrcd, 0, 0, langByte[0], ingamesn, 0, isLocalhost)
logging.Notice(moduleName, "Login (DS)", aurora.Cyan(strconv.FormatUint(userId, 10)), aurora.Cyan(gsbrcd), "devname:", aurora.Cyan(devname), "ingamesn:", aurora.Cyan(ingamesn))
} else {
authToken, challenge = common.MarshalNASAuthToken(gamecd, userId, gsbrcd, 0, 0, langByte[0], "", 0, isLocalhost)
logging.Notice(moduleName, "Login (DS)", aurora.Cyan(strconv.FormatUint(userId, 10)), aurora.Cyan(gsbrcd), "devname:", aurora.Cyan(devname))
}
// wii
case 1:
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
}
authToken, challenge = common.MarshalNASAuthToken(gamecd, userId, gsbrcd, cfcInt, regionByte[0], langByte[0], fields["ingamesn"], 1, isLocalhost)
logging.Notice(moduleName, "Login (Wii)", aurora.Cyan(strconv.FormatUint(userId, 10)), aurora.Cyan(gsbrcd), "ingamesn:", aurora.Cyan(fields["ingamesn"]))
}
if hasProfaneName {
param["returncd"] = "040"
} else {
param["returncd"] = "001"
}
param["challenge"] = challenge
param["token"] = authToken
return param
}
func svcloc(fields map[string]string) map[string]string {
param := map[string]string{
"retry": "0",
"datetime": getDateTime(),
"returncd": "007",
"statusdata": "Y",
}
authToken := "NDS/SVCLOC/TOKEN"
switch fields["svc"] {
default:
param["servicetoken"] = authToken
param["svchost"] = "n/a"
case "9000":
param["token"] = authToken
param["svchost"] = "dls1.nintendowifi.net"
case "9001":
param["servicetoken"] = authToken
param["svchost"] = "dls1.nintendowifi.net"
}
return param
}
func handleProfanity(form url.Values, unitcd string) map[string]string {
var wordsEncoding string
var wordsDefaultEncoding string
var wordsBytes []byte
var words string
var wordsRegion string
var prwords string
if unitcd == "0" {
wordsEncoding = "UTF-16LE"
wordsDefaultEncoding = "UTF-16LE"
} else {
wordsEncoding = "UTF-16BE"
wordsDefaultEncoding = "UTF-16BE"
}
if wencValues, ok := form["wenc"]; ok {
// It's okay for this to error, the real server
// just falls back to the default encoding in
// this case even if it cant properly handle it
wencDecoded, err := common.Base64DwcEncoding.DecodeString(wencValues[0])
if err == nil {
wordsEncoding = string(wencDecoded)
}
}
if wordsEncoding != "UTF-8" && wordsEncoding != "UTF-16LE" && wordsEncoding != "UTF-16BE" {
wordsEncoding = wordsDefaultEncoding
}
// It's okay for this to not exist/be valid, the real
// server will just treat the missing input as a single
// non-profane word
if wordsValues, ok := form["words"]; ok {
wordsDecoded, err := common.Base64DwcEncoding.DecodeString(wordsValues[0])
if err == nil {
wordsBytes = wordsDecoded
}
}
// This field is entirely optional, unsure what
// specifically it does. Adds extra data to the
// reply, probably used for handling the word
// list differently for different regions?
if wordsRegionValues, ok := form["wregion"]; ok {
wordsRegionDecoded, err := common.Base64DwcEncoding.DecodeString(wordsRegionValues[0])
if err == nil {
wordsRegion = string(wordsRegionDecoded)
}
}
if wordsEncoding == "UTF-8" {
words = string(wordsBytes)
} else {
var utf16String []uint16
if wordsEncoding == "UTF-16LE" {
for i := 0; i < len(wordsBytes)/2; i++ {
utf16String = append(utf16String, binary.LittleEndian.Uint16(wordsBytes[i*2:i*2+2]))
}
} else {
for i := 0; i < len(wordsBytes)/2; i++ {
utf16String = append(utf16String, binary.BigEndian.Uint16(wordsBytes[i*2:i*2+2]))
}
}
words = string(utf16.Decode(utf16String))
}
// TODO - Handle wtype? Unsure what this field does, seems to always be an emtpy string
for _, word := range strings.Split(words, "\t") {
if isBadWord, _ := IsBadWord(word); isBadWord {
prwords += "1"
} else {
prwords += "0"
}
}
var returncd string
if strings.Contains(prwords, "1") {
returncd = "040"
} else {
returncd = "000"
}
reply := map[string]string{
"returncd": returncd,
"prwords": prwords,
}
// Only known value of this field that works this way
if wordsRegion == "A" {
// TODO - The real server seems to handle the input words differently per region? These values are supposed to differ from prwords
reply["prwordsA"] = prwords
reply["prwordsC"] = prwords
reply["prwordsE"] = prwords
reply["prwordsJ"] = prwords
reply["prwordsK"] = prwords
reply["prwordsP"] = prwords
}
return reply
}
func dlsCount(fields map[string]string) string {
dlcFolder := filepath.Join(dlcDir, fields["rhgamecd"])
dir, ok := os.ReadDir(dlcFolder)
if ok != nil {
return "0"
}
return strconv.Itoa(len(dir))
}
func isValidRhgamecd(rhgamecd string) bool {
if len(rhgamecd) != 4 {
return false
}
return common.IsUppercaseAlphanumeric(rhgamecd)
}
func getDateTime() string {
t := time.Now().UTC()
return fmt.Sprintf("%04d%02d%02d%02d%02d%02d", t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second())

73
nas/dls.go Normal file
View File

@@ -0,0 +1,73 @@
package nas
import (
"net/http"
"os"
"path/filepath"
"strconv"
"wwfc/common"
"wwfc/logging"
"github.com/logrusorgru/aurora/v3"
)
var dlsActions = map[string]func(moduleName string, fields map[string][]byte) []byte{
"count": dlsCount,
}
func handleDownloadEndpoint(w http.ResponseWriter, r *http.Request) {
moduleName := getModuleName(r)
fields, err := parseAuthRequest(r)
if err != nil {
replyHTTPError(w, 400, "400 Bad Request")
return
}
action := string(fields["action"])
if action == "" {
logging.Error(moduleName, "No action in form")
replyHTTPError(w, 400, "400 Bad Request")
return
}
rhgamecd, ok := fields["rhgamecd"]
if !ok || !isValidRHGameCode(string(rhgamecd)) {
logging.Error(moduleName, "Missing or invalid rhgamecd")
replyHTTPError(w, 400, "400 Bad Request")
return
}
if actionFunc, exists := dlsActions[action]; exists {
reply := actionFunc(moduleName, fields)
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Length", strconv.Itoa(len(reply)))
_, err := w.Write(reply)
if err != nil {
logging.Error(moduleName, "Error writing response:", err)
}
return
}
logging.Error(moduleName, "Unknown action:", aurora.Cyan(action))
replyHTTPError(w, 400, "400 Bad Request")
}
func dlsCount(moduleName string, fields map[string][]byte) []byte {
dlcFolder := filepath.Join(dlcDir, string(fields["rhgamecd"]))
dir, err := os.ReadDir(dlcFolder)
if err != nil {
return []byte{'0', 0}
}
return append([]byte(strconv.Itoa(len(dir))), 0)
}
func isValidRHGameCode(rhgamecd string) bool {
if len(rhgamecd) != 4 {
return false
}
return common.IsUppercaseAlphanumeric(rhgamecd)
}

View File

@@ -24,11 +24,20 @@ var (
var (
authMux = http.NewServeMux()
dlsMux = http.NewServeMux()
sakeMux = http.NewServeMux()
gamestatsMux = http.NewServeMux()
raceMux = http.NewServeMux()
)
var hostMuxes = map[*regexp.Regexp]*http.ServeMux{
regexp.MustCompile(`^(nas|naswii)\.`): authMux,
regexp.MustCompile(`^dls1\.`): dlsMux,
regexp.MustCompile(`(\.|^)gamestats2?\.(gs\.|gamespy\.com$)`): gamestatsMux,
regexp.MustCompile(`(\.|^)sake\.(gs\.|gamespy\.com$)`): sakeMux,
regexp.MustCompile(`(\.|^)race\.(gs\.|gamespy\.com$)`): raceMux,
}
func StartServer(reload bool) {
// Get config
config := common.GetConfig()
@@ -52,9 +61,10 @@ func StartServer(reload bool) {
ReadTimeout: 10 * time.Second,
}
authMux.HandleFunc("/ac", handleAuthRequest)
authMux.HandleFunc("/pr", handleAuthRequest)
authMux.HandleFunc("/download", handleAuthRequest)
authMux.HandleFunc("/ac", handleAuthAccountEndpoint)
authMux.HandleFunc("/pr", handleAuthProfanityEndpoint)
dlsMux.HandleFunc("/download", handleDownloadEndpoint)
if payloadServerAddress != "" {
// Forward the request to the payload server
@@ -111,13 +121,6 @@ func Shutdown() {
}
}
var hostMuxes = map[*regexp.Regexp]*http.ServeMux{
regexp.MustCompile(`^(nas|naswii|dls1)\.`): authMux,
regexp.MustCompile(`(\.|^)gamestats2?\.(gs\.|gamespy\.com$)`): gamestatsMux,
regexp.MustCompile(`(\.|^)sake\.(gs\.|gamespy\.com$)`): sakeMux,
regexp.MustCompile(`(\.|^)race\.(gs\.|gamespy\.com$)`): raceMux,
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
// Check for host-specific muxes
for regex, mux := range hostMuxes {

View File

@@ -2,7 +2,9 @@ package nas
import (
"bufio"
"encoding/binary"
"errors"
"net/http"
"os"
"strings"
"time"
@@ -101,3 +103,105 @@ func isProfanityFileCached() bool {
}
return profanityFileLines != nil && !fileInfo.ModTime().After(lastModTime)
}
func handleAuthProfanityEndpoint(w http.ResponseWriter, r *http.Request) {
form, err := parseAuthRequest(r)
if err != nil {
replyHTTPError(w, 400, "400 Bad Request")
return
}
unitcd := form["unitcd"]
var wordsEncoding string
var wordsDefaultEncoding string
if len(unitcd) != 1 || unitcd[0] != '0' {
wordsEncoding = "UTF-16BE"
wordsDefaultEncoding = "UTF-16BE"
} else {
wordsEncoding = "UTF-16LE"
wordsDefaultEncoding = "UTF-16LE"
}
if wencValues, ok := form["wenc"]; ok {
// It's okay for this to error, the real server
// just falls back to the default encoding in
// this case even if it cant properly handle it
wencDecoded, err := common.Base64DwcEncoding.DecodeString(string(wencValues[0]))
if err == nil {
wordsEncoding = string(wencDecoded)
}
}
if wordsEncoding != "UTF-8" && wordsEncoding != "UTF-16LE" && wordsEncoding != "UTF-16BE" {
wordsEncoding = wordsDefaultEncoding
}
// It's okay for this to not exist/be valid, the real
// server will just treat the missing input as a single
// non-profane word
wordsBytes := []byte{}
if wordsValues, ok := form["words"]; ok {
wordsDecoded, err := common.Base64DwcEncoding.DecodeString(string(wordsValues[0]))
if err == nil {
wordsBytes = wordsDecoded
}
}
// This field is entirely optional, unsure what
// specifically it does. Adds extra data to the
// reply, probably used for handling the word
// list differently for different regions?
var wordsRegion string
if wordsRegionValues, ok := form["wregion"]; ok {
wordsRegionDecoded, err := common.Base64DwcEncoding.DecodeString(string(wordsRegionValues[0]))
if err == nil {
wordsRegion = string(wordsRegionDecoded)
}
}
var words string
switch wordsEncoding {
case "UTF-8":
words = string(wordsBytes)
case "UTF-16LE":
words = common.UTF16Decode(wordsBytes, binary.LittleEndian)
case "UTF-16BE":
words = common.UTF16Decode(wordsBytes, binary.BigEndian)
}
// TODO - Handle wtype? Unsure what this field does, seems to always be an empty string
prwords := ""
for _, word := range strings.Split(words, "\t") {
if isBadWord, _ := IsBadWord(word); isBadWord {
prwords += "1"
} else {
prwords += "0"
}
}
returncd := ""
if strings.Contains(prwords, "1") {
returncd = "040"
} else {
returncd = "000"
}
reply := map[string]string{
"returncd": returncd,
"prwords": prwords,
}
// Only known value of this field that works this way
if wordsRegion == "A" {
// TODO - The real server seems to handle the input words differently per region? These values are supposed to differ from prwords
reply["prwordsA"] = prwords
reply["prwordsC"] = prwords
reply["prwordsE"] = prwords
reply["prwordsJ"] = prwords
reply["prwordsK"] = prwords
reply["prwordsP"] = prwords
}
writeAuthResponse(w, reply)
}

View File

@@ -6,7 +6,6 @@ import (
"net/http"
"sort"
"strconv"
"time"
"wwfc/common"
"wwfc/database"
"wwfc/logging"
@@ -251,17 +250,13 @@ func getRequestIdentity(moduleName string, request StorageRequestCommon) (uint32
return 0, common.GameInfo{}, ResultSecretKeyInvalid
}
profileId, issueTime, err := common.UnmarshalGPCMLoginTicket(request.LoginTicket)
if err != nil {
loginTicket := common.GPCMLoginTicket{}
if err := loginTicket.Unmarshal(request.LoginTicket); err != nil {
logging.Error(moduleName, err)
return 0, common.GameInfo{}, ResultLoginTicketInvalid
}
if issueTime.Add(48 * time.Hour).Before(time.Now()) {
return 0, common.GameInfo{}, ResultLoginTicketExpired
}
return profileId, *gameInfo, ResultSuccess
return loginTicket.ProfileID, *gameInfo, ResultSuccess
}
func createRecord(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestCommon) StorageResponseBody {