265 lines
8.2 KiB
Go
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)
|
|
}
|