package stripe import ( "context" "crypto/tls" "encoding/json" "fmt" "io" "log" "net" "net/http" "strings" "time" "gpt-plus/pkg/httpclient" ) // Fingerprint holds Stripe anti-fraud device IDs. type Fingerprint struct { GUID string MUID string SID string } const fingerprintMaxRetries = 3 // GetFingerprint sends payload to m.stripe.com/6 and retrieves device fingerprint. // Uses the provided (proxied) client. If it fails, returns an error — never fakes GUID. // If browserFP is provided, real fingerprint data is used in the payload. func GetFingerprint(client *httpclient.Client, userAgent, domain, tagVersion string, browserFP ...*BrowserFingerprint) (*Fingerprint, error) { var bfp *BrowserFingerprint if len(browserFP) > 0 { bfp = browserFP[0] } payload := CreateInitPayload(userAgent, domain, tagVersion, bfp) encoded, err := EncodePayload(payload) if err != nil { return nil, fmt.Errorf("encode payload: %w", err) } var lastErr error for attempt := 1; attempt <= fingerprintMaxRetries; attempt++ { fp, err := doFingerprintRequest(nil, client, encoded, userAgent) if err == nil { fp = retryForMUIDSID(fp, nil, client, encoded, userAgent) return fp, nil } lastErr = err if isRetryableError(err) { log.Printf("[stripe] fingerprint attempt %d/%d failed (retryable): %v", attempt, fingerprintMaxRetries, err) if attempt < fingerprintMaxRetries { time.Sleep(time.Duration(attempt*3) * time.Second) } continue } return nil, fmt.Errorf("send fingerprint request: %w", err) } return nil, fmt.Errorf("m.stripe.com unreachable after %d retries: %w", fingerprintMaxRetries, lastErr) } // GetFingerprintDirect sends payload to m.stripe.com/6 using a direct (no-proxy) HTTPS connection. // This bypasses the SOCKS proxy which may block m.stripe.com. // The direct connection is safe: m.stripe.com is Stripe's telemetry endpoint // and does not leak your real IP to OpenAI — it only communicates with Stripe. func GetFingerprintDirect(userAgent, domain, tagVersion string) (*Fingerprint, error) { payload := CreateInitPayload(userAgent, domain, tagVersion) encoded, err := EncodePayload(payload) if err != nil { return nil, fmt.Errorf("encode payload: %w", err) } directClient := &http.Client{ Timeout: 15 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, DialContext: (&net.Dialer{ Timeout: 10 * time.Second, }).DialContext, }, } var lastErr error for attempt := 1; attempt <= fingerprintMaxRetries; attempt++ { fp, err := doFingerprintRequest(directClient, nil, encoded, userAgent) if err == nil { fp = retryForMUIDSID(fp, directClient, nil, encoded, userAgent) return fp, nil } lastErr = err if isRetryableError(err) { log.Printf("[stripe] fingerprint-direct attempt %d/%d failed (retryable): %v", attempt, fingerprintMaxRetries, err) if attempt < fingerprintMaxRetries { time.Sleep(time.Duration(attempt*2) * time.Second) } continue } return nil, fmt.Errorf("send fingerprint-direct request: %w", err) } return nil, fmt.Errorf("m.stripe.com unreachable (direct) after %d retries: %w", fingerprintMaxRetries, lastErr) } // retryForMUIDSID retries fingerprint request if GUID is present but MUID/SID are empty. // Makes up to 2 additional attempts with increasing delay, merging results. func retryForMUIDSID(fp *Fingerprint, directClient *http.Client, proxiedClient *httpclient.Client, encoded, userAgent string) *Fingerprint { if fp.GUID == "" || (fp.MUID != "" && fp.SID != "") { return fp } log.Printf("[stripe] GUID present but MUID/SID empty, retrying to fill...") for i := 0; i < 2; i++ { time.Sleep(time.Duration(500+i*500) * time.Millisecond) retryFP, err := doFingerprintRequest(directClient, proxiedClient, encoded, userAgent) if err != nil { log.Printf("[stripe] MUID/SID retry %d failed: %v", i+1, err) continue } if fp.MUID == "" && retryFP.MUID != "" { fp.MUID = retryFP.MUID } if fp.SID == "" && retryFP.SID != "" { fp.SID = retryFP.SID } if fp.MUID != "" && fp.SID != "" { log.Printf("[stripe] MUID/SID filled after retry %d", i+1) break } } return fp } // GetFingerprintAuto tries the proxied client first; if it fails, falls back to direct connection. func GetFingerprintAuto(ctx context.Context, client *httpclient.Client, userAgent, domain, tagVersion string) (*Fingerprint, error) { fp, err := GetFingerprint(client, userAgent, domain, tagVersion) if err == nil { return fp, nil } log.Printf("[stripe] proxy fingerprint failed (%v), falling back to direct connection", err) return GetFingerprintDirect(userAgent, domain, tagVersion) } // doFingerprintRequest executes a single POST to m.stripe.com/6. // Exactly one of directClient / proxiedClient must be non-nil. func doFingerprintRequest(directClient *http.Client, proxiedClient *httpclient.Client, encodedPayload, userAgent string) (*Fingerprint, error) { req, err := http.NewRequest("POST", "https://m.stripe.com/6", strings.NewReader(encodedPayload)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } // Set proper headers — missing these caused silent rejections req.Header.Set("User-Agent", userAgent) req.Header.Set("Content-Type", "text/plain;charset=UTF-8") req.Header.Set("Origin", "https://js.stripe.com") req.Header.Set("Referer", "https://js.stripe.com/") req.Header.Set("Accept", "*/*") req.Header.Set("Accept-Language", "en-US,en;q=0.9") var resp *http.Response if directClient != nil { resp, err = directClient.Do(req) } else { resp, err = proxiedClient.Do(req) } if err != nil { return nil, err } defer resp.Body.Close() // safe: one request per call, no loop body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fingerprint request failed: status %d, body: %s", resp.StatusCode, string(body)) } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse fingerprint response: %w", err) } fp := &Fingerprint{} if v, ok := result["guid"].(string); ok { fp.GUID = v } if v, ok := result["muid"].(string); ok { fp.MUID = v } if v, ok := result["sid"].(string); ok { fp.SID = v } // GUID is critical — if missing, Stripe WILL trigger 3DS or decline if fp.GUID == "" { return nil, fmt.Errorf("m.stripe.com returned empty GUID (response: %s)", string(body)) } // MUID/SID can sometimes be empty on first call, but GUID is the key signal if fp.MUID == "" { log.Printf("[stripe] WARNING: MUID empty in fingerprint response, proceeding with GUID only") } if fp.SID == "" { log.Printf("[stripe] WARNING: SID empty in fingerprint response, proceeding with GUID only") } return fp, nil } // isRetryableError checks if the error is a transient network issue worth retrying. func isRetryableError(err error) bool { if err == nil { return false } msg := err.Error() return strings.Contains(msg, "connection refused") || strings.Contains(msg, "connection reset") || strings.Contains(msg, "i/o timeout") || strings.Contains(msg, "no such host") || strings.Contains(msg, "network is unreachable") }