// Package htmlgame holds the plumbing shared by the bot's Telegram HTML5 // games: the public base URL, signed Play-link tokens, the HTTP security // headers and JSON helpers of a game's API, embedded page assets, score // reporting with setGameScore, and a per-user request limiter. // // Each game keeps its own claims type, token key label, messages and routes; // this package only owns the parts that must behave the same everywhere. package htmlgame import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "strings" "time" ) // MaxTokenBytes bounds a token before any work is spent on it. const MaxTokenBytes = 1024 var ( // ErrBadToken is a token that is malformed, forged or names no valid // game message. ErrBadToken = errors.New("htmlgame: bad token") // ErrTokenExpired is a correctly signed token past its expiry. ErrTokenExpired = errors.New("htmlgame: token expired") ) // Claims is what a game decodes a token into. Valid checks the game's own // rules (for example exactly one message address); ExpiresAt is the unix // second the token stops working. type Claims interface { Valid() bool ExpiresAt() int64 } // DeriveKey is HMAC-SHA256(key=root, label). Each game and purpose uses its // own label, so a key derived for one never verifies for another. An empty // root yields nil. func DeriveKey(root []byte, label string) []byte { if len(root) == 0 { return nil } mac := hmac.New(sha256.New, root) mac.Write([]byte(label)) return mac.Sum(nil) } // Sign encodes payload as base64url(json) "." base64url(HMAC-SHA256). func Sign(key []byte, payload any) (string, error) { raw, err := json.Marshal(payload) if err != nil { return "", err } body := base64.RawURLEncoding.EncodeToString(raw) return body + "." + base64.RawURLEncoding.EncodeToString(tokenMAC(key, body)), nil } func tokenMAC(key []byte, body string) []byte { mac := hmac.New(sha256.New, key) mac.Write([]byte(body)) return mac.Sum(nil) } // Verify checks the signature first, then decodes into dst (a pointer) and // checks the claims and the expiry. It returns ErrBadToken or // ErrTokenExpired. func Verify(key []byte, token string, now time.Time, dst Claims) error { if len(key) == 0 || len(token) > MaxTokenBytes { return ErrBadToken } body, sig, ok := strings.Cut(token, ".") if !ok { return ErrBadToken } gotMAC, err := base64.RawURLEncoding.DecodeString(sig) if err != nil || !hmac.Equal(gotMAC, tokenMAC(key, body)) { return ErrBadToken } payload, err := base64.RawURLEncoding.DecodeString(body) if err != nil { return ErrBadToken } if err := json.Unmarshal(payload, dst); err != nil || !dst.Valid() { return ErrBadToken } if now.Unix() >= dst.ExpiresAt() { return ErrTokenExpired } return nil }