Files
Ilia Denisov 8881214213 R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
2026-06-10 16:56:03 +02:00

144 lines
4.2 KiB
Go

// 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.
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)
}