Files
gpt-plus-gpt/pkg/auth/sentinel.go
2026-03-15 20:48:19 +08:00

265 lines
8.2 KiB
Go

package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"math"
"math/big"
"net/http"
"strings"
"time"
"gpt-plus/pkg/httpclient"
)
const (
sentinelReqURL = "https://sentinel.openai.com/backend-api/sentinel/req"
maxPowAttempts = 500000
errorPrefix = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D"
defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
)
// SentinelGenerator generates openai-sentinel-token values.
type SentinelGenerator struct {
DeviceID string
SID string // session UUID
client *httpclient.Client
}
// NewSentinelGenerator creates a new SentinelGenerator with a random DeviceID.
func NewSentinelGenerator(client *httpclient.Client) *SentinelGenerator {
return &SentinelGenerator{
DeviceID: generateUUID(),
SID: generateUUID(),
client: client,
}
}
// NewSentinelGeneratorWithDeviceID creates a SentinelGenerator reusing an existing DeviceID.
// Use this to keep sentinel.DeviceID consistent with oai-device-id in request headers.
func NewSentinelGeneratorWithDeviceID(client *httpclient.Client, deviceID string) *SentinelGenerator {
return &SentinelGenerator{
DeviceID: deviceID,
SID: generateUUID(),
client: client,
}
}
// sentinelRequirements is the response from the sentinel /req endpoint.
type sentinelRequirements struct {
Token string `json:"token"`
ProofOfWork struct {
Required bool `json:"required"`
Seed string `json:"seed"`
Difficulty string `json:"difficulty"`
} `json:"proofofwork"`
}
// fnv1a32 computes FNV-1a 32-bit hash with murmurhash3 finalizer mixing,
// matching the sentinel SDK's JavaScript implementation.
func fnv1a32(text string) string {
h := uint32(2166136261) // FNV offset basis
for _, c := range []byte(text) {
h ^= uint32(c)
h *= 16777619 // FNV prime
}
// xorshift mixing (murmurhash3 finalizer)
h ^= h >> 16
h *= 2246822507
h ^= h >> 13
h *= 3266489909
h ^= h >> 16
return fmt.Sprintf("%08x", h)
}
// buildConfig constructs the 19-element browser environment config array.
func (sg *SentinelGenerator) buildConfig() []interface{} {
now := time.Now().UTC()
dateStr := now.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
navRandom := cryptoRandomFloat()
perfNow := 100.5 + cryptoRandomFloat()*50
timeOrigin := float64(now.UnixMilli()) - perfNow
config := []interface{}{
"1920x1080", // [0] screen
dateStr, // [1] date
4294705152, // [2] jsHeapSizeLimit
0, // [3] nonce placeholder
defaultUA, // [4] userAgent
"https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js", // [5] script src
nil, // [6] script version
nil, // [7] data build
DefaultLanguage, // [8] language
0, // [9] elapsed_ms placeholder
navRandom, // [10] random float
0, // [11] nav val
0, // [12] doc key
10, // [13] win key
perfNow, // [14] performance.now
sg.SID, // [15] session UUID
"", // [16] URL params
16, // [17] hardwareConcurrency
timeOrigin, // [18] timeOrigin
}
return config
}
// base64EncodeConfig JSON-encodes the config array with compact separators, then base64 encodes it.
func base64EncodeConfig(config []interface{}) string {
jsonBytes, _ := json.Marshal(config)
return base64.StdEncoding.EncodeToString(jsonBytes)
}
// runCheck performs a single PoW check iteration.
func (sg *SentinelGenerator) runCheck(startTime time.Time, seed, difficulty string, config []interface{}, nonce int) string {
config[3] = nonce
config[9] = int(time.Since(startTime).Milliseconds())
data := base64EncodeConfig(config)
hashHex := fnv1a32(seed + data)
diffLen := len(difficulty)
if diffLen > 0 && hashHex[:diffLen] <= difficulty {
return data + "~S"
}
return ""
}
// solvePoW runs the PoW loop and returns the solution token.
func (sg *SentinelGenerator) solvePoW(seed, difficulty string) string {
startTime := time.Now()
config := sg.buildConfig()
for i := 0; i < maxPowAttempts; i++ {
result := sg.runCheck(startTime, seed, difficulty, config, i)
if result != "" {
return "gAAAAAB" + result
}
}
// PoW failed after max attempts, return error token
errData := base64EncodeConfig([]interface{}{nil})
return "gAAAAAB" + errorPrefix + errData
}
// generateRequirementsToken creates a requirements token (no server seed needed).
// This is the SDK's getRequirementsToken() equivalent.
func (sg *SentinelGenerator) generateRequirementsToken() string {
config := sg.buildConfig()
config[3] = 1
config[9] = 5 + int(cryptoRandomFloat()*45) // simulate small delay 5-50ms
data := base64EncodeConfig(config)
return "gAAAAAC" + data // note: prefix is C, not B
}
// fetchChallenge calls the sentinel backend to get challenge data.
// Retries on 403/network errors since the proxy rotates IP every ~30s.
func (sg *SentinelGenerator) fetchChallenge(ctx context.Context, client *httpclient.Client, flow string) (*sentinelRequirements, error) {
const maxRetries = 5
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
result, err := sg.doFetchChallenge(ctx, client, flow)
if err == nil {
return result, nil
}
lastErr = err
log.Printf("[sentinel] attempt %d/%d failed: %v", attempt, maxRetries, err)
if attempt < maxRetries {
// Wait for proxy IP rotation, use shorter waits for early attempts
wait := time.Duration(3*attempt) * time.Second
log.Printf("[sentinel] waiting %v for proxy IP rotation before retry...", wait)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
}
return nil, fmt.Errorf("sentinel failed after %d attempts: %w", maxRetries, lastErr)
}
func (sg *SentinelGenerator) doFetchChallenge(ctx context.Context, client *httpclient.Client, flow string) (*sentinelRequirements, error) {
pToken := sg.generateRequirementsToken()
reqBody := map[string]string{
"p": pToken,
"id": sg.DeviceID,
"flow": flow,
}
bodyBytes, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sentinelReqURL, strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, fmt.Errorf("create sentinel request: %w", err)
}
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
req.Header.Set("Origin", "https://sentinel.openai.com")
req.Header.Set("Referer", "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6")
req.Header.Set("sec-ch-ua", `"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("sentinel request: %w", err)
}
body, err := httpclient.ReadBody(resp)
if err != nil {
return nil, fmt.Errorf("read sentinel response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("sentinel returned %d", resp.StatusCode)
}
var result sentinelRequirements
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse sentinel response: %w", err)
}
return &result, nil
}
// GenerateToken builds the full openai-sentinel-token JSON string for a given flow.
func (sg *SentinelGenerator) GenerateToken(ctx context.Context, client *httpclient.Client, flow string) (string, error) {
if client == nil {
client = sg.client
}
challenge, err := sg.fetchChallenge(ctx, client, flow)
if err != nil {
return "", fmt.Errorf("fetch sentinel challenge: %w", err)
}
cValue := challenge.Token
var pValue string
if challenge.ProofOfWork.Required && challenge.ProofOfWork.Seed != "" {
pValue = sg.solvePoW(challenge.ProofOfWork.Seed, challenge.ProofOfWork.Difficulty)
} else {
pValue = sg.generateRequirementsToken()
}
token := map[string]string{
"p": pValue,
"t": "",
"c": cValue,
"id": sg.DeviceID,
"flow": flow,
}
tokenBytes, _ := json.Marshal(token)
return string(tokenBytes), nil
}
// cryptoRandomFloat returns a cryptographically random float64 in [0, 1).
func cryptoRandomFloat() float64 {
n, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
return float64(n.Int64()) / float64(math.MaxInt64)
}