fix(grok): self-heal x-statsig-id from live homepage seed+curves
Derive the 49-byte header seed and the salt F per-session from the grok homepage (browser-free tls-client GET), instead of hardcoding constants that go stale on every grok web rebuild (403 anti-bot). F is reproduced natively in Go (curve keyframe sampling: cubic-bezier easing, color lerp, rotation matrix, JS-exact number->hex). Static constants remain as env-overridable fallback. Adds offline regression test against server-verified ground-truth triples.
This commit is contained in:
@@ -22,8 +22,10 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
http "github.com/bogdanfinn/fhttp"
|
||||||
@@ -111,6 +113,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
c.ensureChallenge(ctx, client, token)
|
||||||
// gRPC-web empty message frame: 1-byte flag + 4-byte length (both zero).
|
// gRPC-web empty message frame: 1-byte flag + 4-byte length (both zero).
|
||||||
body := []byte{0, 0, 0, 0, 0}
|
body := []byte{0, 0, 0, 0, 0}
|
||||||
req, err := http.NewRequest(http.MethodPost, apiBase+"/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", strings.NewReader(string(body)))
|
req, err := http.NewRequest(http.MethodPost, apiBase+"/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", strings.NewReader(string(body)))
|
||||||
@@ -193,6 +196,7 @@ func (c *Client) FetchSubscription(ctx context.Context, token string) (*Subscrip
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
c.ensureChallenge(ctx, client, token)
|
||||||
req, err := http.NewRequest(http.MethodGet, apiBase+"/rest/subscriptions", nil)
|
req, err := http.NewRequest(http.MethodGet, apiBase+"/rest/subscriptions", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -256,6 +260,7 @@ func (c *Client) FetchSession(ctx context.Context, token string) (email, userID
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
c.ensureChallenge(ctx, client, token)
|
||||||
req, err := http.NewRequest(http.MethodGet, apiBase+"/api/auth/session", nil)
|
req, err := http.NewRequest(http.MethodGet, apiBase+"/api/auth/session", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
@@ -291,23 +296,52 @@ func (c *Client) FetchSession(ctx context.Context, token string) (email, userID
|
|||||||
return strings.TrimSpace(body.Session.Email), strings.TrimSpace(body.Session.UserID), nil
|
return strings.TrimSpace(body.Session.Email), strings.TrimSpace(body.Session.UserID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// statsig challenge constants for the current grok.com web build. They rotate
|
// grok's x-statsig-id is validated per-session: the 49-byte header is 0x00 plus
|
||||||
// when grok ships a new build; override at runtime via env vars
|
// a 48-byte "seed" published in the homepage <meta name="grok-site-verification">,
|
||||||
// (GROK_STATSIG_HEADER_HEX / GROK_STATSIG_SUFFIX / GROK_STATSIG_TRAILER).
|
// and the salt embeds a 3-byte "F" the server recomputes from that seed and the
|
||||||
|
// page's curve set. Both rotate whenever grok ships a new web build, so hardcoded
|
||||||
|
// constants go stale (403 anti-bot). We self-heal: fetch the homepage per session
|
||||||
|
// (browser-free, tls-client), derive seed + F, and cache. The static defaults
|
||||||
|
// below (env-overridable) are a last-resort fallback if the fetch fails.
|
||||||
// statsigEpoch is the challenge epoch (2023-05-01 00:00 UTC).
|
// statsigEpoch is the challenge epoch (2023-05-01 00:00 UTC).
|
||||||
const (
|
const (
|
||||||
statsigEpoch = 1682924400
|
statsigEpoch = 1682924400
|
||||||
defaultStatsigHeader = "00e1ebcb2cac08f42039de1eb4d8534da581482fd09ccc95e06e3f03a3e9ddde02eb50b70c2efeaec6401f5d9b5ed329d4"
|
defaultStatsigHeader = "00a1adb5012bd32f844f4426c62680d91c6129361eb9459a759710e179a888a99b21678e1f0b1e8952de6a6b3ca019f74b"
|
||||||
defaultStatsigSuffix = "obfiowerehiring4fa399100100"
|
defaultStatsigSuffix = "obfiowerehiringd244100f5c28f5c28f5c047ae147ae147b047ae147ae147b0f5c28f5c28f5c00"
|
||||||
defaultStatsigTrailer = 3
|
defaultStatsigTrailer = 3
|
||||||
|
statsigSaltPrefix = "obfiowerehiring"
|
||||||
|
statsigAnimDuration = 4096
|
||||||
|
statsigTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
statsigHeader = resolveStatsigHeader()
|
statsigHeader = resolveStatsigHeader()
|
||||||
statsigSuffix = envOr("GROK_STATSIG_SUFFIX", defaultStatsigSuffix)
|
statsigSuffix = envOr("GROK_STATSIG_SUFFIX", defaultStatsigSuffix)
|
||||||
statsigTrailer = resolveStatsigTrailer()
|
statsigTrailer = resolveStatsigTrailer()
|
||||||
|
|
||||||
|
statsigMetaRe = regexp.MustCompile(`name="grok[^"]*verification"[^>]*content="([^"]+)"`)
|
||||||
|
|
||||||
|
statsigMu sync.Mutex
|
||||||
|
statsigCache = map[string]statsigChallenge{} // keyed by sso token
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// statsigChallenge is a resolved, self-consistent (header, salt) pair for one
|
||||||
|
// grok session, derived from the homepage seed + curves.
|
||||||
|
type statsigChallenge struct {
|
||||||
|
header []byte
|
||||||
|
suffix string
|
||||||
|
trailer byte
|
||||||
|
fetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsigCurve is one entry of the per-load curve set injected via the Next.js
|
||||||
|
// RSC stream; the server uses it (with the seed) to recompute F.
|
||||||
|
type statsigCurve struct {
|
||||||
|
Color []int `json:"color"`
|
||||||
|
Deg int `json:"deg"`
|
||||||
|
Bezier []int `json:"bezier"`
|
||||||
|
}
|
||||||
|
|
||||||
func resolveStatsigHeader() []byte {
|
func resolveStatsigHeader() []byte {
|
||||||
h := envOr("GROK_STATSIG_HEADER_HEX", defaultStatsigHeader)
|
h := envOr("GROK_STATSIG_HEADER_HEX", defaultStatsigHeader)
|
||||||
b, err := hex.DecodeString(h)
|
b, err := hex.DecodeString(h)
|
||||||
@@ -333,19 +367,288 @@ func envOr(key, def string) string {
|
|||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureChallenge refreshes the cached (header, salt) for the session if missing
|
||||||
|
// or stale. Any failure is non-fatal: statsigID then falls back to the static
|
||||||
|
// defaults. An explicit env override disables dynamic fetching entirely.
|
||||||
|
func (c *Client) ensureChallenge(ctx context.Context, client tlsclient.HttpClient, token string) {
|
||||||
|
if token == "" || client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statsigMu.Lock()
|
||||||
|
cur, ok := statsigCache[token]
|
||||||
|
fresh := ok && time.Since(cur.fetchedAt) < statsigTTL
|
||||||
|
statsigMu.Unlock()
|
||||||
|
if fresh {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch, err := fetchStatsigChallenge(ctx, client, token)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statsigMu.Lock()
|
||||||
|
statsigCache[token] = ch
|
||||||
|
statsigMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchStatsigChallenge does a browser-free homepage GET and derives a
|
||||||
|
// self-consistent (header, salt) pair: header = 0x00 + seed, salt = prefix + F.
|
||||||
|
func fetchStatsigChallenge(ctx context.Context, client tlsclient.HttpClient, token string) (statsigChallenge, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, apiBase+"/", nil)
|
||||||
|
if err != nil {
|
||||||
|
return statsigChallenge{}, err
|
||||||
|
}
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
req.Header = http.Header{
|
||||||
|
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
||||||
|
"accept-language": {"en-US,en;q=0.9"},
|
||||||
|
"user-agent": {userAgent},
|
||||||
|
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||||
|
http.HeaderOrderKey: {"accept", "accept-language", "user-agent", "cookie"},
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return statsigChallenge{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return statsigChallenge{}, err
|
||||||
|
}
|
||||||
|
html := string(raw)
|
||||||
|
|
||||||
|
mm := statsigMetaRe.FindStringSubmatch(html)
|
||||||
|
if mm == nil {
|
||||||
|
return statsigChallenge{}, errors.New("statsig: seed meta not found")
|
||||||
|
}
|
||||||
|
seed, err := decodeStatsigSeed(mm[1])
|
||||||
|
if err != nil {
|
||||||
|
return statsigChallenge{}, err
|
||||||
|
}
|
||||||
|
curves, err := parseStatsigCurves(html)
|
||||||
|
if err != nil {
|
||||||
|
return statsigChallenge{}, err
|
||||||
|
}
|
||||||
|
tail, err := computeStatsigTail(seed, curves)
|
||||||
|
if err != nil {
|
||||||
|
return statsigChallenge{}, err
|
||||||
|
}
|
||||||
|
header := make([]byte, 0, 49)
|
||||||
|
header = append(header, 0x00)
|
||||||
|
header = append(header, seed...)
|
||||||
|
return statsigChallenge{
|
||||||
|
header: header,
|
||||||
|
suffix: statsigSaltPrefix + tail,
|
||||||
|
trailer: defaultStatsigTrailer,
|
||||||
|
fetchedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStatsigSeed(s string) ([]byte, error) {
|
||||||
|
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 48 {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
b, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(s, "="))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("statsig: seed decode: %w", err)
|
||||||
|
}
|
||||||
|
if len(b) != 48 {
|
||||||
|
return nil, fmt.Errorf("statsig: seed len %d", len(b))
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatsigCurves extracts the [[{color,deg,bezier}...]...] array from the
|
||||||
|
// RSC-escaped homepage HTML (the one immediately followed by color/bezier keys).
|
||||||
|
func parseStatsigCurves(html string) ([][]statsigCurve, error) {
|
||||||
|
marker := -1
|
||||||
|
for from := 0; ; {
|
||||||
|
i := strings.Index(html[from:], "curves")
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += from
|
||||||
|
end := i + 160
|
||||||
|
if end > len(html) {
|
||||||
|
end = len(html)
|
||||||
|
}
|
||||||
|
w := html[i:end]
|
||||||
|
if strings.Contains(w, "color") && strings.Contains(w, "bezier") {
|
||||||
|
marker = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
from = i + 6
|
||||||
|
}
|
||||||
|
if marker < 0 {
|
||||||
|
return nil, errors.New("statsig: curves not found")
|
||||||
|
}
|
||||||
|
rel := strings.IndexByte(html[marker:], '[')
|
||||||
|
if rel < 0 {
|
||||||
|
return nil, errors.New("statsig: curves array start not found")
|
||||||
|
}
|
||||||
|
start := marker + rel
|
||||||
|
depth, stop := 0, -1
|
||||||
|
for k := start; k < len(html); k++ {
|
||||||
|
switch html[k] {
|
||||||
|
case '[':
|
||||||
|
depth++
|
||||||
|
case ']':
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
stop = k + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stop > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stop < 0 {
|
||||||
|
return nil, errors.New("statsig: curves array end not found")
|
||||||
|
}
|
||||||
|
sub := strings.ReplaceAll(html[start:stop], `\`, "")
|
||||||
|
var out [][]statsigCurve
|
||||||
|
if err := json.Unmarshal([]byte(sub), &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("statsig: curves json: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeStatsigTail reproduces the browser's 3-byte color + 6-number transform
|
||||||
|
// matrix "F" tail: it selects a curve by the seed, samples the curve's keyframe
|
||||||
|
// animation (color lerp + rotate) at a seed-derived paused currentTime, and
|
||||||
|
// serializes getComputedStyle(color)+getComputedStyle(transform) exactly as the
|
||||||
|
// signer does (each number -> Number(v.toFixed(2)).toString(16), '.'/'-' stripped).
|
||||||
|
func computeStatsigTail(seed []byte, curves [][]statsigCurve) (string, error) {
|
||||||
|
if len(seed) < 40 {
|
||||||
|
return "", errors.New("statsig: short seed")
|
||||||
|
}
|
||||||
|
if len(curves) == 0 {
|
||||||
|
return "", errors.New("statsig: no curves")
|
||||||
|
}
|
||||||
|
group := int(seed[5]) % len(curves)
|
||||||
|
if len(curves[group]) == 0 {
|
||||||
|
return "", errors.New("statsig: empty curve group")
|
||||||
|
}
|
||||||
|
idx := int(seed[17]) % len(curves[group])
|
||||||
|
cv := curves[group][idx]
|
||||||
|
if len(cv.Color) < 6 || len(cv.Bezier) < 4 {
|
||||||
|
return "", errors.New("statsig: malformed curve")
|
||||||
|
}
|
||||||
|
|
||||||
|
n := (int(seed[19]) % 16) * (int(seed[15]) % 16) * (int(seed[39]) % 16)
|
||||||
|
currentTime := jsRound(float64(n)/10) * 10
|
||||||
|
progress := float64(currentTime) / statsigAnimDuration
|
||||||
|
|
||||||
|
x1 := toFixed2(float64(cv.Bezier[0]) / 255)
|
||||||
|
y1 := toFixed2(float64(cv.Bezier[1])*2/255 - 1)
|
||||||
|
x2 := toFixed2(float64(cv.Bezier[2]) / 255)
|
||||||
|
y2 := toFixed2(float64(cv.Bezier[3])*2/255 - 1)
|
||||||
|
eased := cubicBezierEase(x1, y1, x2, y2, progress)
|
||||||
|
|
||||||
|
nums := make([]float64, 0, 9)
|
||||||
|
for k := 0; k < 3; k++ {
|
||||||
|
v := jsRound(float64(cv.Color[k]) + (float64(cv.Color[k+3])-float64(cv.Color[k]))*eased)
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
if v > 255 {
|
||||||
|
v = 255
|
||||||
|
}
|
||||||
|
nums = append(nums, float64(v))
|
||||||
|
}
|
||||||
|
theta := jsRound(float64(cv.Deg)*300/255 + 60)
|
||||||
|
rad := float64(theta) * eased * math.Pi / 180
|
||||||
|
cos, sin := math.Cos(rad), math.Sin(rad)
|
||||||
|
nums = append(nums, cos, sin, -sin, cos, 0, 0)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, v := range nums {
|
||||||
|
b.WriteString(jsHex(v))
|
||||||
|
}
|
||||||
|
out := strings.NewReplacer(".", "", "-", "").Replace(b.String())
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsRound matches JavaScript Math.round (round half up toward +Inf).
|
||||||
|
func jsRound(x float64) int {
|
||||||
|
return int(math.Floor(x + 0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
// toFixed2 matches JavaScript Number(v.toFixed(2)).
|
||||||
|
func toFixed2(v float64) float64 {
|
||||||
|
f, _ := strconv.ParseFloat(strconv.FormatFloat(v, 'f', 2, 64), 64)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsHex matches JavaScript Number(v.toFixed(2)).toString(16).
|
||||||
|
func jsHex(v float64) string {
|
||||||
|
v = toFixed2(v)
|
||||||
|
neg := ""
|
||||||
|
if v < 0 {
|
||||||
|
neg = "-"
|
||||||
|
v = -v
|
||||||
|
}
|
||||||
|
ip := int64(math.Floor(v))
|
||||||
|
frac := v - float64(ip)
|
||||||
|
s := neg + strconv.FormatInt(ip, 16)
|
||||||
|
if frac == 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
const digits = "0123456789abcdef"
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(s)
|
||||||
|
b.WriteByte('.')
|
||||||
|
for i := 0; i < 20 && frac != 0; i++ {
|
||||||
|
frac *= 16
|
||||||
|
d := int(frac)
|
||||||
|
b.WriteByte(digits[d])
|
||||||
|
frac -= float64(d)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cubicBezierEase evaluates a CSS cubic-bezier(x1,y1,x2,y2) easing at input
|
||||||
|
// fraction p: solve X(t)=p for t (bisection), then return Y(t).
|
||||||
|
func cubicBezierEase(x1, y1, x2, y2, p float64) float64 {
|
||||||
|
bez := func(t, a, b float64) float64 {
|
||||||
|
mt := 1 - t
|
||||||
|
return 3*a*mt*mt*t + 3*b*mt*t*t + t*t*t
|
||||||
|
}
|
||||||
|
lo, hi := 0.0, 1.0
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
mid := (lo + hi) / 2
|
||||||
|
if bez(mid, x1, x2) < p {
|
||||||
|
lo = mid
|
||||||
|
} else {
|
||||||
|
hi = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bez((lo+hi)/2, y1, y2)
|
||||||
|
}
|
||||||
|
|
||||||
// statsigID reproduces grok.com's x-statsig-id anti-bot token for a request. The
|
// statsigID reproduces grok.com's x-statsig-id anti-bot token for a request. The
|
||||||
// token binds to the request METHOD and URL path and to a coarse timestamp, so
|
// token binds to the request METHOD and URL path and to a coarse timestamp, so
|
||||||
// it must be regenerated per request. See the package doc for the layout.
|
// it must be regenerated per request. See the package doc for the layout. It uses
|
||||||
func statsigID(path, method string) string {
|
// the session's self-healed (header, salt) when available, else static defaults.
|
||||||
|
func statsigID(path, method, token string) string {
|
||||||
|
header, suffix, trailer := statsigHeader, statsigSuffix, statsigTrailer
|
||||||
|
statsigMu.Lock()
|
||||||
|
if ch, ok := statsigCache[token]; ok {
|
||||||
|
header, suffix, trailer = ch.header, ch.suffix, ch.trailer
|
||||||
|
}
|
||||||
|
statsigMu.Unlock()
|
||||||
|
|
||||||
counter := uint32(time.Now().Unix() - statsigEpoch)
|
counter := uint32(time.Now().Unix() - statsigEpoch)
|
||||||
sig := fmt.Sprintf("%s!%s!%d%s", method, path, counter, statsigSuffix)
|
sig := fmt.Sprintf("%s!%s!%d%s", method, path, counter, suffix)
|
||||||
hash := sha256.Sum256([]byte(sig))
|
hash := sha256.Sum256([]byte(sig))
|
||||||
|
|
||||||
raw := make([]byte, 0, 70)
|
raw := make([]byte, 0, 70)
|
||||||
raw = append(raw, statsigHeader...)
|
raw = append(raw, header...)
|
||||||
raw = binary.LittleEndian.AppendUint32(raw, counter)
|
raw = binary.LittleEndian.AppendUint32(raw, counter)
|
||||||
raw = append(raw, hash[:16]...)
|
raw = append(raw, hash[:16]...)
|
||||||
raw = append(raw, statsigTrailer)
|
raw = append(raw, trailer)
|
||||||
|
|
||||||
key := byte(rand.IntN(256))
|
key := byte(rand.IntN(256))
|
||||||
for i := range raw {
|
for i := range raw {
|
||||||
@@ -364,7 +667,7 @@ func (c *Client) applyHeaders(req *http.Request, token string, extra map[string]
|
|||||||
"origin": {origin},
|
"origin": {origin},
|
||||||
"referer": {origin + "/"},
|
"referer": {origin + "/"},
|
||||||
"user-agent": {userAgent},
|
"user-agent": {userAgent},
|
||||||
"x-statsig-id": {statsigID(req.URL.Path, req.Method)},
|
"x-statsig-id": {statsigID(req.URL.Path, req.Method, token)},
|
||||||
"x-xai-request-id": {uuid.NewString()},
|
"x-xai-request-id": {uuid.NewString()},
|
||||||
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
|
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
|
||||||
"sec-ch-ua-mobile": {"?0"},
|
"sec-ch-ua-mobile": {"?0"},
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package grok
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ground-truth (seed, curves, F) triples. The first row is server-verified
|
||||||
|
// (captured from a real grok browser token that returned 200); the remainder
|
||||||
|
// exercise every curve group / currentTime branch and are checked against the
|
||||||
|
// reference implementation the algorithm was reverse-engineered against.
|
||||||
|
var statsigTriples = []struct {
|
||||||
|
seedHex string
|
||||||
|
wantM string
|
||||||
|
}{
|
||||||
|
{seedHex: "732c9800d181e47c9b2a2705713306f42a51a10d69c6a6c32e53b26d1599b0b75399035a9953fedf05903cb9eb70a989", wantM: "ff75470a3d70a3d70a3d80c51eb851eb8520c51eb851eb8520a3d70a3d70a3d800"},
|
||||||
|
{seedHex: "732c9800d1802530bb1d6d057133064d2aa5a1ca69c6a6c32e53b26d1599b0b75399035a9953fe1805903cb9eb70a989", wantM: "27fa10fd70a3d70a3d7028f5c28f5c28f6028f5c28f5c28f60fd70a3d70a3d700"},
|
||||||
|
{seedHex: "732c9800d180237b2ed91e057133062c2a13a1de69c6a6c32e53b26d1599b0b75399035a9953fed605903cb9eb70a989", wantM: "079ff0d1eb851eb851e80947ae147ae14780947ae147ae14780d1eb851eb851e800"},
|
||||||
|
{seedHex: "732c9800d180197117449405713306722a3fa11f69c6a6c32e53b26d1599b0b75399035a9953fecb05903cb9eb70a989", wantM: "195fe0fae147ae147ae035c28f5c28f5c2035c28f5c28f5c20fae147ae147ae00"},
|
||||||
|
{seedHex: "732c9800d1815c3460be3105713306492ad6a13c69c6a6c32e53b26d1599b0b75399035a9953fe9d05903cb9eb70a989", wantM: "ff6ab5035c28f5c28f5c20fae147ae147ae0fae147ae147ae035c28f5c28f5c200"},
|
||||||
|
{seedHex: "732c9800d181daa0eee8b9057133061e2a20a16969c6a6c32e53b26d1599b0b75399035a9953fefe05903cb9eb70a989", wantM: "3da0d70e3d70a3d70a3d8075c28f5c28f5c4075c28f5c28f5c40e3d70a3d70a3d800"},
|
||||||
|
{seedHex: "732c9800d1812999fdafe5057133067f2a99a15c69c6a6c32e53b26d1599b0b75399035a9953fe7c05903cb9eb70a989", wantM: "1cac4c0451eb851eb8520f5c28f5c28f5c0f5c28f5c28f5c0451eb851eb85200"},
|
||||||
|
{seedHex: "732c9800d18254af4dfad705713306252a93a13c69c6a6c32e53b26d1599b0b75399035a9953fed605903cb9eb70a989", wantM: "b40480d70a3d70a3d70808a3d70a3d70a408a3d70a3d70a40d70a3d70a3d70800"},
|
||||||
|
{seedHex: "732c9800d182b3fee9232f05713306272a14a1a069c6a6c32e53b26d1599b0b75399035a9953feae05903cb9eb70a989", wantM: "13757a100100"},
|
||||||
|
{seedHex: "732c9800d1829ee491c5b105713306f22a8aa12169c6a6c32e53b26d1599b0b75399035a9953fe1f05903cb9eb70a989", wantM: "eb4fb9100a3d70a3d70a3d800a3d70a3d70a3d8100"},
|
||||||
|
{seedHex: "732c9800d1833bfc1e6f9305713306ec2a0ba1b569c6a6c32e53b26d1599b0b75399035a9953fe5605903cb9eb70a989", wantM: "8a56650fae147ae147ae02b851eb851eb8602b851eb851eb860fae147ae147ae00"},
|
||||||
|
{seedHex: "732c9800d183fe2955e5cd057133067e2a42a1cb69c6a6c32e53b26d1599b0b75399035a9953fec805903cb9eb70a989", wantM: "b242950deb851eb851eb807d70a3d70a3d707d70a3d70a3d70deb851eb851eb800"},
|
||||||
|
{seedHex: "732c9800d183d4b7c2764d05713306462a8ea1dc69c6a6c32e53b26d1599b0b75399035a9953fe8e05903cb9eb70a989", wantM: "1e70d8070a3d70a3d70a40e66666666666680e6666666666668070a3d70a3d70a400"}}
|
||||||
|
|
||||||
|
const statsigTestCurves = `[[{"color":[48,44,6,37,198,15],"deg":192,"bezier":[118,76,158,16]},{"color":[224,216,196,111,43,97],"deg":119,"bezier":[67,167,95,219]},{"color":[90,235,250,5,223,64],"deg":104,"bezier":[100,57,106,204]},{"color":[6,109,253,44,29,224],"deg":151,"bezier":[204,60,142,122]},{"color":[81,0,121,208,228,133],"deg":181,"bezier":[182,89,105,123]},{"color":[31,3,160,181,226,184],"deg":98,"bezier":[207,150,215,136]},{"color":[231,243,81,28,109,131],"deg":175,"bezier":[17,103,7,81]},{"color":[222,250,130,169,55,247],"deg":141,"bezier":[21,238,12,84]},{"color":[74,62,116,145,209,185],"deg":109,"bezier":[0,74,58,233]},{"color":[204,168,203,138,107,125],"deg":214,"bezier":[41,13,239,45]},{"color":[246,116,162,162,84,234],"deg":127,"bezier":[160,202,52,76]},{"color":[138,202,210,36,61,195],"deg":234,"bezier":[31,50,177,229]},{"color":[47,46,196,176,79,255],"deg":221,"bezier":[39,14,9,76]},{"color":[245,164,227,71,201,110],"deg":190,"bezier":[193,208,125,9]},{"color":[199,253,44,33,130,240],"deg":191,"bezier":[168,247,61,37]},{"color":[19,91,5,180,202,243],"deg":216,"bezier":[96,152,42,123]}],[{"color":[42,154,230,170,197,128],"deg":108,"bezier":[160,52,34,184]},{"color":[224,132,83,0,231,162],"deg":238,"bezier":[227,37,122,102]},{"color":[24,82,237,199,100,74],"deg":130,"bezier":[186,106,188,209]},{"color":[47,50,169,65,124,44],"deg":228,"bezier":[198,22,146,225]},{"color":[111,131,95,195,131,73],"deg":207,"bezier":[134,146,202,172]},{"color":[192,10,166,28,236,3],"deg":219,"bezier":[85,77,157,235]},{"color":[236,139,199,72,233,250],"deg":197,"bezier":[180,9,79,147]},{"color":[146,195,78,16,231,211],"deg":93,"bezier":[123,18,87,27]},{"color":[168,211,49,42,124,18],"deg":172,"bezier":[232,171,120,118]},{"color":[43,146,96,11,203,53],"deg":146,"bezier":[11,4,83,198]},{"color":[183,97,101,38,115,41],"deg":159,"bezier":[69,223,197,236]},{"color":[78,73,207,132,109,134],"deg":49,"bezier":[59,18,133,168]},{"color":[239,153,225,207,127,157],"deg":194,"bezier":[13,206,154,182]},{"color":[19,146,127,191,68,199],"deg":165,"bezier":[148,212,7,16]},{"color":[93,225,249,144,88,255],"deg":215,"bezier":[62,30,77,69]},{"color":[203,79,164,145,3,20],"deg":81,"bezier":[228,28,93,130]}],[{"color":[171,53,4,125,232,43],"deg":122,"bezier":[136,9,128,97]},{"color":[143,97,205,57,63,69],"deg":192,"bezier":[232,20,219,189]},{"color":[28,173,18,107,158,44],"deg":255,"bezier":[93,187,184,198]},{"color":[170,17,62,142,200,32],"deg":29,"bezier":[14,31,12,97]},{"color":[19,117,122,173,239,66],"deg":74,"bezier":[117,176,139,212]},{"color":[213,151,230,112,224,255],"deg":25,"bezier":[1,223,72,233]},{"color":[153,131,51,105,69,47],"deg":108,"bezier":[123,177,126,140]},{"color":[120,114,44,151,88,83],"deg":165,"bezier":[16,104,134,75]},{"color":[245,145,194,75,120,26],"deg":142,"bezier":[79,235,38,43]},{"color":[147,63,50,255,239,106],"deg":190,"bezier":[122,143,160,150]},{"color":[233,78,184,130,25,123],"deg":54,"bezier":[87,18,184,226]},{"color":[162,180,233,70,57,249],"deg":87,"bezier":[123,238,61,124]},{"color":[146,95,56,171,38,240],"deg":239,"bezier":[241,134,228,44]},{"color":[111,11,149,62,208,177],"deg":70,"bezier":[103,149,4,37]},{"color":[159,128,118,21,197,153],"deg":175,"bezier":[246,215,172,236]},{"color":[194,131,68,247,215,108],"deg":30,"bezier":[23,91,151,231]}],[{"color":[239,129,141,243,85,208],"deg":38,"bezier":[252,248,245,195]},{"color":[205,56,138,49,126,99],"deg":107,"bezier":[72,85,228,91]},{"color":[185,54,148,122,170,158],"deg":192,"bezier":[33,88,51,136]},{"color":[7,14,75,26,23,41],"deg":93,"bezier":[153,21,55,147]},{"color":[245,211,213,64,5,253],"deg":49,"bezier":[253,19,106,155]},{"color":[144,232,165,21,114,130],"deg":200,"bezier":[193,179,133,226]},{"color":[34,196,100,42,114,0],"deg":52,"bezier":[4,4,71,65]},{"color":[195,8,130,102,201,141],"deg":210,"bezier":[8,173,23,33]},{"color":[86,136,44,95,223,62],"deg":249,"bezier":[220,98,68,113]},{"color":[205,48,9,247,236,71],"deg":75,"bezier":[163,240,28,25]},{"color":[43,190,29,239,55,135],"deg":146,"bezier":[109,245,34,188]},{"color":[146,91,92,2,3,251],"deg":97,"bezier":[183,188,95,157]},{"color":[40,213,196,70,81,174],"deg":120,"bezier":[153,197,61,201]},{"color":[131,92,180,68,131,214],"deg":251,"bezier":[94,191,198,89]},{"color":[47,113,219,96,115,228],"deg":238,"bezier":[22,35,60,63]},{"color":[246,244,203,196,78,136],"deg":44,"bezier":[88,23,205,184]}]]`
|
||||||
|
|
||||||
|
// TestComputeStatsigTail is the offline regression test for the F derivation.
|
||||||
|
func TestComputeStatsigTail(t *testing.T) {
|
||||||
|
var curves [][]statsigCurve
|
||||||
|
if err := json.Unmarshal([]byte(statsigTestCurves), &curves); err != nil {
|
||||||
|
t.Fatalf("curves: %v", err)
|
||||||
|
}
|
||||||
|
for i, tc := range statsigTriples {
|
||||||
|
seed, err := hex.DecodeString(tc.seedHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("[%d] seed hex: %v", i, err)
|
||||||
|
}
|
||||||
|
got, err := computeStatsigTail(seed, curves)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("[%d] computeStatsigTail: %v", i, err)
|
||||||
|
}
|
||||||
|
if got != tc.wantM {
|
||||||
|
t.Errorf("[%d] group=%d\n got=%s\nwant=%s", i, int(seed[5])%len(curves), got, tc.wantM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSelfHealStatsigE2E exercises the full browser-free self-healing path:
|
||||||
|
// fetch the homepage, derive seed+F, cache the challenge, then hit the
|
||||||
|
// anti-bot-gated conversations/new endpoint. Requires a live GROK_TOK and no
|
||||||
|
// GROK_STATSIG_* env overrides.
|
||||||
|
func TestSelfHealStatsigE2E(t *testing.T) {
|
||||||
|
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
|
||||||
|
if token == "" {
|
||||||
|
t.Skip("no GROK_TOK")
|
||||||
|
}
|
||||||
|
c := NewClient("")
|
||||||
|
client, err := c.newTLSClient()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
c.ensureChallenge(ctx, client, token)
|
||||||
|
statsigMu.Lock()
|
||||||
|
ch, ok := statsigCache[token]
|
||||||
|
statsigMu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("challenge not cached (homepage fetch/derive failed)")
|
||||||
|
}
|
||||||
|
t.Logf("dynamic header[:6]=%x suffix=%s", ch.header[:6], ch.suffix)
|
||||||
|
|
||||||
|
body, err := c.postStream(ctx, client, token, "/rest/app-chat/conversations/new", map[string]any{
|
||||||
|
"temporary": true,
|
||||||
|
"modelName": "grok-3",
|
||||||
|
"message": "hi",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("conversations/new: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("OK bytes=%d head=%.80s", len(body), strings.ReplaceAll(body, "\n", " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateVideoE2E generates a real grok video using only the dynamic
|
||||||
|
// self-healed statsig (no env overrides). Requires a live GROK_TOK.
|
||||||
|
func TestGenerateVideoE2E(t *testing.T) {
|
||||||
|
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
|
||||||
|
if token == "" {
|
||||||
|
t.Skip("no GROK_TOK")
|
||||||
|
}
|
||||||
|
c := NewClient("")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
data, meta, err := c.GenerateVideo(ctx, token, "a cat playing piano", "16:9", "720p", 6, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateVideo: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("video bytes=%d meta=%v", len(data), meta)
|
||||||
|
if len(data) < 1<<20 {
|
||||||
|
t.Fatalf("video too small: %d bytes", len(data))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data[:16]), "ftyp") {
|
||||||
|
t.Fatalf("not an mp4: % x", data[:16])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user