// Package initdata validates Telegram Mini App launch data (initData). It lives in // the connector because the HMAC secret is the bot token, which is held only here // (ARCHITECTURE.md ยง12); the gateway calls the connector's ValidateInitData RPC // instead of validating the launch data itself. package initdata import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "net/url" "sort" "strconv" "strings" "time" ) // ErrInvalidInitData is returned when initData fails HMAC validation, is missing // the hash, is malformed, or is older than the freshness window. var ErrInvalidInitData = errors.New("initdata: invalid telegram init data") // defaultMaxAge bounds how old a validated initData payload may be. const defaultMaxAge = 24 * time.Hour // User is the identity extracted from a validated initData payload. ExternalID is // the Telegram user id used as the identities external_id; LanguageCode seeds a // new account's preferred language (Stage 9). type User struct { ExternalID string Username string FirstName string LanguageCode string } // Validator validates Telegram Web App launch data and returns the authenticated // user. It is an interface so the connector can be tested with a fixture. type Validator interface { Validate(initData string) (User, error) } // HMACValidator validates initData against a bot token per Telegram's documented // algorithm: the data-check string is HMAC-SHA256'd under a secret derived from // the bot token, and the result is compared with the supplied hash. type HMACValidator struct { botToken string maxAge time.Duration now func() time.Time } // NewHMACValidator constructs a validator for botToken. func NewHMACValidator(botToken string) *HMACValidator { return &HMACValidator{botToken: botToken, maxAge: defaultMaxAge, now: time.Now} } // Validate parses and verifies initData, returning the authenticated user. func (v *HMACValidator) Validate(initData string) (User, error) { values, err := url.ParseQuery(initData) if err != nil { return User{}, ErrInvalidInitData } hash := values.Get("hash") if hash == "" { return User{}, ErrInvalidInitData } values.Del("hash") if !v.checkSignature(values, hash) { return User{}, ErrInvalidInitData } if err := v.checkFreshness(values.Get("auth_date")); err != nil { return User{}, err } return parseUser(values.Get("user")) } // checkSignature recomputes the HMAC over the sorted data-check string and // compares it with hash in constant time. func (v *HMACValidator) checkSignature(values url.Values, hash string) bool { keys := make([]string, 0, len(values)) for k := range values { keys = append(keys, k) } sort.Strings(keys) lines := make([]string, 0, len(keys)) for _, k := range keys { lines = append(lines, k+"="+values.Get(k)) } dataCheck := strings.Join(lines, "\n") secret := hmacSHA256([]byte("WebAppData"), []byte(v.botToken)) want := hmacSHA256(secret, []byte(dataCheck)) got, err := hex.DecodeString(hash) if err != nil { return false } return hmac.Equal(want, got) } // checkFreshness rejects an auth_date older than the validator's window. func (v *HMACValidator) checkFreshness(authDate string) error { if authDate == "" { return ErrInvalidInitData } secs, err := strconv.ParseInt(authDate, 10, 64) if err != nil { return ErrInvalidInitData } if v.now().Sub(time.Unix(secs, 0)) > v.maxAge { return ErrInvalidInitData } return nil } // parseUser extracts the user id, names and language from the user JSON field. func parseUser(userJSON string) (User, error) { if userJSON == "" { return User{}, ErrInvalidInitData } var u struct { ID int64 `json:"id"` Username string `json:"username"` FirstName string `json:"first_name"` LanguageCode string `json:"language_code"` } if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 { return User{}, ErrInvalidInitData } return User{ ExternalID: strconv.FormatInt(u.ID, 10), Username: u.Username, FirstName: u.FirstName, LanguageCode: u.LanguageCode, }, nil } // hmacSHA256 returns HMAC-SHA256(message) under key. func hmacSHA256(key, message []byte) []byte { h := hmac.New(sha256.New, key) h.Write(message) return h.Sum(nil) }