diff --git a/docs/superpowers/plans/2026-04-07-register-sentinel-live-fix.md b/docs/superpowers/plans/2026-04-07-register-sentinel-live-fix.md new file mode 100644 index 0000000..599dcd7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-register-sentinel-live-fix.md @@ -0,0 +1,55 @@ +# Register Sentinel Live Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace static register sentinel tokens with live `/backend-api/sentinel/req` + `turnstile.dx` generation. + +**Architecture:** Python keeps the HTTP registration flow. A local Node runner handles the current sentinel proof generation and VM execution. Python assembles the final sentinel header and injects it into register/create-account requests. + +**Tech Stack:** Python 3.11+, `curl_cffi`, built-in `unittest`, Node.js CommonJS. + +--- + +### Task 1: Add failing tests for the new sentinel integration + +**Files:** +- Create: `tests/test_sentinel_solver.py` +- Create: `tests/test_register_live_sentinel.py` + +- [ ] **Step 1: Write failing tests for Python sentinel solving and register flow usage** +- [ ] **Step 2: Run `python -m unittest tests.test_sentinel_solver tests.test_register_live_sentinel -v` and verify failure** + +### Task 2: Add the Node VM / proof runner + +**Files:** +- Create: `src/sentinel_vm.js` +- Create: `src/sentinel_runner.js` + +- [ ] **Step 1: Add the VM executor and runner CLI** +- [ ] **Step 2: Run a targeted local smoke command against the runner** + +### Task 3: Add the Python sentinel solver + +**Files:** +- Create: `src/sentinel_solver.py` +- Modify: `pyproject.toml` + +- [ ] **Step 1: Implement SDK version discovery, Node subprocess calls, req fetch, and final token assembly** +- [ ] **Step 2: Run sentinel solver unit tests and verify pass** + +### Task 4: Wire register flow to the live solver + +**Files:** +- Modify: `src/chatgpt_register_http_reverse.py` + +- [ ] **Step 1: Inject the sentinel solver into register flow and remove static fallback use** +- [ ] **Step 2: Run register flow unit tests and verify pass** + +### Task 5: Verify end-to-end targeted checks + +**Files:** +- Modify as needed based on failures from prior tasks + +- [ ] **Step 1: Run `python -m unittest tests.test_sentinel_solver tests.test_register_live_sentinel -v`** +- [ ] **Step 2: Run one targeted Node runner smoke command** +- [ ] **Step 3: Summarize limitations and next checks** diff --git a/docs/superpowers/specs/2026-04-07-register-sentinel-design.md b/docs/superpowers/specs/2026-04-07-register-sentinel-design.md new file mode 100644 index 0000000..ce2cb62 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-register-sentinel-design.md @@ -0,0 +1,36 @@ +# Register Sentinel Live Generation Design + +## Goal +Replace the expired static `nodatadog.js` sentinel fallback in the register flow with live sentinel generation based on the current `/backend-api/sentinel/req -> turnstile.dx -> VM` flow. + +## Scope +- Only the `register` flow is in scope. +- `checkout` may benefit indirectly because it reuses registration, but payment flow changes are out of scope. +- `codex-login` is explicitly out of scope. + +## Design +1. Add a Python `SentinelSolver` that: + - fetches the current sentinel SDK bootstrap to discover the active sentinel version; + - asks a local Node runner to generate the current `p` proof token using the live `getConfig()` / proof-of-work logic; + - calls `https://sentinel.openai.com/backend-api/sentinel/req` with `{p,id,flow}`; + - asks the Node runner to execute `turnstile.dx` and returns the raw encoded VM output; + - builds the final sentinel header as JSON with `p`, `t`, `c`, `id`, and `flow`. +2. Add a Node runtime that contains: + - a readable VM executor for `turnstile.dx`; + - a small browser-like environment shim; + - the current proof generation logic derived from the active SDK. +3. Wire `ChatGPTRegisterHTTPReverse.register()` to generate live sentinel tokens for: + - `username_password_create` + - `oauth_create_account` +4. Remove the old static-capture fallback from the register path. + +## Failure Policy +If any live sentinel step fails (SDK version fetch, Node runtime, req response parse, VM execution, or final token assembly), registration stops immediately with a descriptive error. + +## Files +- New: `src/sentinel_solver.py` +- New: `src/sentinel_vm.js` +- New: `src/sentinel_runner.js` +- Modify: `src/chatgpt_register_http_reverse.py` +- Modify: `pyproject.toml` +- New tests under `tests/` diff --git a/pyproject.toml b/pyproject.toml index c26211f..910ccb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ package-dir = { gptplus_auto = "src" } include-package-data = true [tool.setuptools.package-data] -gptplus_auto = ["nodatadog.js"] +gptplus_auto = ["nodatadog.js", "sentinel_runner.js", "sentinel_vm.js"] [dependency-groups] dev = [] diff --git a/src/chatgpt_register_http_reverse.py b/src/chatgpt_register_http_reverse.py index 06a49f7..9c3c3f0 100644 --- a/src/chatgpt_register_http_reverse.py +++ b/src/chatgpt_register_http_reverse.py @@ -1,28 +1,27 @@ from __future__ import annotations """Pure HTTP registration experiment with detailed auth-state diagnostics.""" -import os -import re import uuid -from pathlib import Path from typing import Optional from urllib.parse import unquote, urlencode, urljoin, urlparse try: from .http_client import HTTPClient + from .sentinel_solver import SentinelSolver from .vmail_client import BaseMailClient except ImportError: # pragma: no cover - allow direct script execution from source tree from http_client import HTTPClient + from sentinel_solver import SentinelSolver from vmail_client import BaseMailClient class ChatGPTRegisterHTTPReverse: - def __init__(self, http: HTTPClient, mail: BaseMailClient): + def __init__(self, http: HTTPClient, mail: BaseMailClient, sentinel_solver: SentinelSolver | None = None): self.http = http self.mail = mail self.base = "https://chatgpt.com" self.auth_base = "https://auth.openai.com" - self.module_dir = Path(__file__).resolve().parent + self.sentinel_solver = sentinel_solver or SentinelSolver(http) def _cookie_rows(self) -> list[tuple[str, str, str]]: rows = [] @@ -79,27 +78,14 @@ class ChatGPTRegisterHTTPReverse: except Exception: return None - def _load_captured_sentinel(self, flow_name: str) -> str: - candidates = [ - self.module_dir / "nodatadog.js", - self.module_dir.parent / "nodatadog.js", - Path.cwd() / "nodatadog.js", - ] - content = "" - for path in candidates: - try: - content = path.read_text(encoding="utf-8") - break - except FileNotFoundError: - continue - if not content: + def _build_live_sentinel_token(self, flow_name: str) -> str: + return self.sentinel_solver.build_token(flow_name) + + def _build_live_session_observer_token(self, flow_name: str) -> str: + build_session_observer_token = getattr(self.sentinel_solver, "build_session_observer_token", None) + if not callable(build_session_observer_token): return "" - pattern = re.compile(r'"openai-sentinel-token": "((?:[^"\\]|\\.)*flow\\"\\"%s(?:[^"\\]|\\.)*)"' % re.escape(flow_name)) - match = pattern.search(content) - if not match: - return "" - # Keep the captured literal as-is. The request header in the capture uses this exact string form. - return match.group(1) + return build_session_observer_token(flow_name) def _bootstrap_chatgpt(self): print("[2/9] Bootstrapping chatgpt.com cookies...") @@ -217,6 +203,47 @@ class ChatGPTRegisterHTTPReverse: def _register_endpoint(self, auth_url: str) -> str: return f"{self._accounts_api_base(auth_url)}/user/register" + def _authorize_entry_page(self, auth_page_url: str) -> str: + if "log-in-or-create-account" in auth_page_url: + return auth_page_url + return f"{self.auth_base}/log-in-or-create-account?usernameKind=email" + + def _authorize_continue( + self, + accounts_api_base: str, + auth_page_url: str, + email: str, + sentinel: str = "", + ) -> str: + print("[5/10] Calling authorize/continue before register...") + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Origin": self.auth_base, + "Referer": auth_page_url, + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + } + if sentinel: + headers["openai-sentinel-token"] = sentinel + response = self.http.request( + "POST", + f"{accounts_api_base}/authorize/continue", + json={ + "username": {"kind": "email", "value": email}, + "screen_hint": "login_or_signup", + }, + headers=headers, + ) + data = self._json_or_none(response) or {} + print(f" Authorize-continue status: {response.status_code} sentinel={bool(sentinel)}") + if response.status_code != 200 or not data.get("continue_url"): + raise RuntimeError(f"Authorize continue blocked: {self._response_excerpt(response)}") + continue_url = urljoin(auth_page_url, data["continue_url"]) + print(f" Authorize continue URL: {continue_url}") + return self._open_continue_url(continue_url, auth_page_url, "[5/10]") + def _attempt_register(self, register_url: str, auth_page_url: str, email: str, password: str, sentinel: str = ""): headers = { "Content-Type": "application/json", @@ -301,7 +328,14 @@ class ChatGPTRegisterHTTPReverse: self._print_cookie_summary("After validate") return data["continue_url"] - def _attempt_create_account(self, create_account_url: str, name: str, referer: str, sentinel: str = ""): + def _attempt_create_account( + self, + create_account_url: str, + name: str, + referer: str, + sentinel: str = "", + sentinel_so: str = "", + ): print("[8/9] Creating account profile via pure HTTP...") headers = { "Content-Type": "application/json", @@ -311,6 +345,8 @@ class ChatGPTRegisterHTTPReverse: } if sentinel: headers["openai-sentinel-token"] = sentinel + if sentinel_so: + headers["openai-sentinel-so-token"] = sentinel_so response = self.http.request( "POST", create_account_url, @@ -361,29 +397,39 @@ class ChatGPTRegisterHTTPReverse: auth_url = self._signin_auth0(email, csrf_token) auth_page_url = self._follow_auth_redirects(auth_url) + authorize_entry_page = self._authorize_entry_page(auth_page_url) accounts_api_base = self._accounts_api_base(auth_url) register_url = self._register_endpoint(auth_url) - print(f"[5/9] Pure HTTP register endpoint: {register_url}") + print(f"[6/10] Pure HTTP register endpoint: {register_url}") - response, data = self._attempt_register(register_url, auth_page_url, email, password) - if response.status_code != 200 or not data or not data.get("continue_url"): - captured = self._load_captured_sentinel("username_password_create") - if not captured: - raise RuntimeError( - f"Register failed without sentinel and no captured sentinel found: {self._response_excerpt(response)}" - ) - print("[6/9] Retrying register with captured sentinel token...") - response, data = self._attempt_register(register_url, auth_page_url, email, password, sentinel=captured) + print("[5/10] Generating live sentinel token for authorize_continue...") + authorize_sentinel = self._build_live_sentinel_token("authorize_continue") + register_referer = self._authorize_continue( + accounts_api_base, + authorize_entry_page, + email, + sentinel=authorize_sentinel, + ) + + print("[7/10] Generating live sentinel token for register...") + register_sentinel = self._build_live_sentinel_token("username_password_create") + response, data = self._attempt_register( + register_url, + register_referer, + email, + password, + sentinel=register_sentinel, + ) if response.status_code != 200 or not data or not data.get("continue_url"): raise RuntimeError(f"Register blocked: {self._response_excerpt(response)}") continue_url1 = data["continue_url"] print(f" Continue URL: {continue_url1}") - email_verification_url = self._open_continue_url(continue_url1, auth_page_url, "[6/9]") + email_verification_url = self._open_continue_url(continue_url1, register_referer, "[7/10]") - print("[7/9] Waiting for OTP...") + print("[8/10] Waiting for OTP...") otp = self.mail.wait_for_otp(mailbox, timeout=120) print(f" OTP: {otp}") @@ -393,22 +439,17 @@ class ChatGPTRegisterHTTPReverse: email_verification_url, ) - about_you_url = self._open_continue_url(continue_url2, email_verification_url, "[8/9]") + about_you_url = self._open_continue_url(continue_url2, email_verification_url, "[9/10]") + print("[10/10] Generating live sentinel token for create_account...") + create_sentinel = self._build_live_sentinel_token("oauth_create_account") + create_so_sentinel = self._build_live_session_observer_token("oauth_create_account") create_response, create_data = self._attempt_create_account( f"{accounts_api_base}/create_account", name, about_you_url, + sentinel=create_sentinel, + sentinel_so=create_so_sentinel, ) - if create_response.status_code != 200 or not create_data or not create_data.get("continue_url"): - captured = self._load_captured_sentinel("oauth_create_account") - if captured: - print("[9/9] Retrying create_account with captured sentinel token...") - create_response, create_data = self._attempt_create_account( - f"{accounts_api_base}/create_account", - name, - about_you_url, - sentinel=captured, - ) if create_response.status_code != 200 or not create_data or not create_data.get("continue_url"): raise RuntimeError(f"Create account blocked: {self._response_excerpt(create_response)}") diff --git a/src/config.py b/src/config.py index 3163aa9..f30b638 100644 --- a/src/config.py +++ b/src/config.py @@ -8,6 +8,7 @@ class Settings(BaseSettings): yescaptcha_api_key: str = Field(default="", env="YESCAPTCHA_API_KEY") socks5_proxy: str = Field(default="", env="SOCKS5_PROXY") mail_provider: str = Field(default="vmail", env="MAIL_PROVIDER") # "vmail", "mailtm", or "yyds" + yyds_mail_domain: str = Field(default="", env="YYDS_MAIL_DOMAIN") # Payment info card_number: str = Field(default="", env="CARD_NUMBER") diff --git a/src/sentinel_runner.js b/src/sentinel_runner.js new file mode 100644 index 0000000..2361cff --- /dev/null +++ b/src/sentinel_runner.js @@ -0,0 +1,429 @@ +'use strict'; + +const crypto = require('node:crypto'); +const { performance } = require('node:perf_hooks'); +const { runFromInputs } = require('./sentinel_vm'); + +const ERROR_PREFIX = 'wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D'; +const MAX_ATTEMPTS = 500000; +const DEFAULT_TIMEOUT_MS = 2000; +const DEFAULT_JS_HEAP_SIZE_LIMIT = 4294967296; +const SENTINEL_BOOTSTRAP_URL = 'https://sentinel.openai.com/backend-api/sentinel/sdk.js'; + +function encodeBase64Binary(value) { + if (typeof btoa === 'function') { + return btoa(value); + } + return Buffer.from(String(value), 'binary').toString('base64'); +} + +function encodeUtf8Json(value) { + const json = JSON.stringify(value); + const bytes = new TextEncoder().encode(json); + return encodeBase64Binary(String.fromCharCode(...bytes)); +} + +function buildFailureMessage(error) { + return ERROR_PREFIX + encodeUtf8Json(String(error ?? 'e')); +} + +function hashHex(input) { + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619) >>> 0; + } + hash ^= hash >>> 16; + hash = Math.imul(hash, 2246822507) >>> 0; + hash ^= hash >>> 13; + hash = Math.imul(hash, 3266489909) >>> 0; + hash ^= hash >>> 16; + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +function randomPick(items) { + if (!Array.isArray(items) || items.length === 0) { + return undefined; + } + return items[Math.floor(Math.random() * items.length)]; +} + +function makeStorage() { + const store = new Map(); + return { + length: 0, + getItem(key) { + return store.has(String(key)) ? store.get(String(key)) : null; + }, + setItem(key, value) { + store.set(String(key), String(value)); + this.length = store.size; + }, + removeItem(key) { + store.delete(String(key)); + this.length = store.size; + }, + clear() { + store.clear(); + this.length = 0; + }, + }; +} + +function makeElement(tagName, attributes = {}) { + return { + tagName: String(tagName).toUpperCase(), + style: {}, + children: [], + hidden: false, + visibility: 'visible', + ariaHidden: 'false', + innerText: '', + textContent: '', + ...attributes, + appendChild(child) { + this.children.push(child); + return child; + }, + removeChild(child) { + this.children = this.children.filter(item => item !== child); + }, + getBoundingClientRect() { + return { + x: 0, + y: 0, + top: 0, + left: 0, + right: 84, + bottom: 16, + width: 84, + height: 16, + }; + }, + getAttribute(name) { + return this.attributes?.[name] ?? null; + }, + }; +} + +function parseAcceptLanguage(value) { + const raw = String(value || 'zh-CN,zh;q=0.9,en;q=0.8'); + const entries = raw.split(',').map(item => item.trim()).filter(Boolean); + const languages = entries.map(item => item.split(';', 1)[0]); + return { + language: languages[0] ?? 'zh-CN', + languages: languages.length > 0 ? languages : ['zh-CN', 'zh', 'en-US', 'en'], + }; +} + +function buildNavigator(options) { + const { language, languages } = parseAcceptLanguage(options.accept_language); + const navigatorProto = { + appName: 'Netscape', + mediaDevices: '[object MediaDevices]', + serviceWorker: '[object ServiceWorkerContainer]', + userActivation: '[object UserActivation]', + connection: '[object NetworkInformation]', + pdfViewerEnabled: true, + }; + + return Object.assign(Object.create(navigatorProto), { + userAgent: + options.user_agent || + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36', + language, + languages, + platform: options.platform || 'MacIntel', + vendor: options.vendor || 'Google Inc.', + deviceMemory: Number(options.device_memory || 8), + hardwareConcurrency: Number(options.hardware_concurrency || 8), + maxTouchPoints: Number(options.max_touch_points || 0), + webdriver: false, + }); +} + +function createRuntimeEnvironment(options = {}) { + const sdkUrl = options.sdk_url || SENTINEL_BOOTSTRAP_URL; + const sdkVersion = options.sdk_version || '20260219f9f6'; + const search = options.location_search || `?sv=${sdkVersion}`; + const screenWidth = Number(options.screen_width || 1920); + const screenHeight = Number(options.screen_height || 1080); + const navigatorRef = buildNavigator(options); + + const body = makeElement('body'); + body.clientWidth = 1280; + body.clientHeight = 720; + + const documentElement = makeElement('html', { + attributes: { 'data-build': options.data_build ?? null }, + }); + documentElement.clientWidth = 1280; + documentElement.clientHeight = 720; + + const documentRef = { + body, + head: makeElement('head'), + documentElement, + referrer: options.referrer || '', + cookie: options.cookie || '', + visibilityState: 'visible', + scripts: [{ src: sdkUrl }], + createElement(tag) { + return makeElement(tag); + }, + addEventListener() {}, + removeEventListener() {}, + }; + + const locationRef = { + href: `https://sentinel.openai.com/backend-api/sentinel/frame.html${search}`, + search, + }; + + const windowRef = { + Reflect, + Object, + Math, + Date, + JSON, + URL, + URLSearchParams, + document: documentRef, + navigator: navigatorRef, + location: locationRef, + history: { + length: 2, + state: null, + pushState() {}, + replaceState() {}, + }, + screen: { + width: screenWidth, + height: screenHeight, + availWidth: screenWidth, + availHeight: Math.max(screenHeight - 40, screenHeight), + availLeft: 0, + availTop: 0, + colorDepth: 24, + pixelDepth: 24, + }, + performance: { + now() { + return performance.now(); + }, + timeOrigin: performance.timeOrigin, + memory: { + jsHeapSizeLimit: Number(options.js_heap_size_limit || DEFAULT_JS_HEAP_SIZE_LIMIT), + }, + }, + localStorage: makeStorage(), + sessionStorage: makeStorage(), + setTimeout, + clearTimeout, + }; + + windowRef.window = windowRef; + windowRef.self = windowRef; + windowRef.top = windowRef; + windowRef.globalThis = windowRef; + + return { windowRef, documentRef }; +} + +function randomNavigatorProbe(navigatorRef) { + const key = randomPick(Object.keys(Object.getPrototypeOf(navigatorRef))); + try { + return `${key}−${navigatorRef[key].toString()}`; + } catch { + return `${key}`; + } +} + +function getConfig(runtime, sid) { + const { windowRef, documentRef } = runtime; + const { navigator } = windowRef; + return [ + windowRef.screen?.width + windowRef.screen?.height, + `${new Date()}`, + windowRef.performance?.memory?.jsHeapSizeLimit, + Math.random(), + navigator.userAgent, + randomPick(Array.from(documentRef.scripts || []).map(script => script?.src).filter(Boolean)), + (Array.from(documentRef.scripts || []) + .map(script => script?.src?.match('c/[^/]*/_')) + .filter(match => match?.length)[0] ?? [])[0] ?? documentRef.documentElement.getAttribute('data-build'), + navigator.language, + navigator.languages?.join(','), + Math.random(), + randomNavigatorProbe(navigator), + randomPick(Object.keys(documentRef)), + randomPick(Object.keys(windowRef)), + windowRef.performance.now(), + sid, + [...new URLSearchParams(windowRef.location.search).keys()].join(','), + navigator.hardwareConcurrency, + windowRef.performance.timeOrigin, + Number('ai' in windowRef), + Number('createPRNG' in windowRef), + Number('cache' in windowRef), + Number('data' in windowRef), + Number('solana' in windowRef), + Number('dump' in windowRef), + Number('InstallTrigger' in windowRef), + ]; +} + +function runCheck(startTime, seed, difficulty, config, attempt) { + config[3] = attempt; + config[9] = Math.round(performance.now() - startTime); + const serialized = encodeUtf8Json(config); + const digest = hashHex(seed + serialized); + return digest.substring(0, difficulty.length) <= difficulty ? `${serialized}~S` : null; +} + +function generateAnswerSync(seed, difficulty, runtime, sid) { + const startTime = performance.now(); + try { + const config = getConfig(runtime, sid); + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { + const answer = runCheck(startTime, seed, difficulty, config, attempt); + if (answer) { + return answer; + } + } + } catch (error) { + return buildFailureMessage(error); + } + return buildFailureMessage('proof generation exhausted'); +} + +function buildRequirementsToken(options = {}) { + const runtime = createRuntimeEnvironment({ + ...options, + sdk_url: options.sdk_version_url || options.sdk_url, + location_search: options.location_search_prepare ?? options.location_search, + }); + const requirementsSeed = `${Math.random()}`; + const sid = crypto.randomUUID(); + const answer = generateAnswerSync(requirementsSeed, '0', runtime, sid); + if (!answer || answer.startsWith(ERROR_PREFIX)) { + throw new Error(`sentinel prepare failed: ${answer || 'empty answer'}`); + } + return answer.startsWith('gAAAAAC') ? answer : `gAAAAAC${answer}`; +} + +function buildEnforcementToken(chatReq, options = {}) { + const proof = chatReq?.proofofwork; + if (!proof?.required) { + return null; + } + + const seed = proof.seed; + const difficulty = proof.difficulty; + if (typeof seed !== 'string' || typeof difficulty !== 'string') { + throw new Error(`invalid enforcement proof payload: ${JSON.stringify(proof)}`); + } + + const runtime = createRuntimeEnvironment({ + ...options, + sdk_url: options.sdk_bootstrap_url || options.sdk_url || SENTINEL_BOOTSTRAP_URL, + location_search: options.location_search_enforcement ?? options.location_search ?? '', + }); + const sid = crypto.randomUUID(); + const answer = generateAnswerSync(seed, difficulty, runtime, sid); + if (!answer || answer.startsWith(ERROR_PREFIX)) { + throw new Error(`sentinel enforcement failed: ${answer || 'empty answer'}`); + } + return answer.startsWith('gAAAAAB') ? answer : `gAAAAAB${answer}`; +} + +async function solveTurnstile(options = {}) { + if (!options.p) { + throw new Error('missing proof token p'); + } + if (!options.dx) { + throw new Error('missing turnstile dx payload'); + } + + const runtime = createRuntimeEnvironment(options); + const result = await runFromInputs(options.p, options.dx, { + windowRef: runtime.windowRef, + documentRef: runtime.documentRef, + timeoutMs: Number(options.timeout_ms || DEFAULT_TIMEOUT_MS), + }); + + if (!result || result.channel !== 'resolve' || !result.encodedValue) { + throw new Error(`turnstile vm failed: ${JSON.stringify(result)}`); + } + return result.encodedValue; +} + +async function solveSessionObserver(options = {}) { + if (!options.snapshot_dx) { + throw new Error('missing session observer snapshot_dx payload'); + } + + const runtime = createRuntimeEnvironment({ + ...options, + sdk_url: options.sdk_bootstrap_url || options.sdk_url || SENTINEL_BOOTSTRAP_URL, + location_search: options.location_search_enforcement ?? options.location_search ?? '', + }); + const result = await runFromInputs('', options.snapshot_dx, { + windowRef: runtime.windowRef, + documentRef: runtime.documentRef, + timeoutMs: Number(options.timeout_ms || DEFAULT_TIMEOUT_MS), + }); + + if (!result || result.channel !== 'resolve' || !result.encodedValue) { + throw new Error(`session observer vm failed: ${JSON.stringify(result)}`); + } + return result.encodedValue; +} + +async function readStdinJson() { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + if (chunks.length === 0) { + return {}; + } + const text = Buffer.concat(chunks).toString('utf8').trim(); + return text ? JSON.parse(text) : {}; +} + +async function main() { + const mode = process.argv[2]; + const input = await readStdinJson(); + + if (mode === 'prepare') { + const p = buildRequirementsToken(input); + process.stdout.write(JSON.stringify({ p })); + return; + } + + if (mode === 'enforcement') { + const p = buildEnforcementToken(input.chat_req, input); + process.stdout.write(JSON.stringify({ p })); + return; + } + + if (mode === 'turnstile') { + const t = await solveTurnstile(input); + process.stdout.write(JSON.stringify({ t })); + return; + } + + if (mode === 'session-observer') { + const so = await solveSessionObserver(input); + process.stdout.write(JSON.stringify({ so })); + return; + } + + throw new Error(`unknown mode: ${mode || ''}`); +} + +main().catch(error => { + process.stderr.write(`${error.stack || String(error)}\n`); + process.exit(1); +}); diff --git a/src/sentinel_solver.py b/src/sentinel_solver.py new file mode 100644 index 0000000..1deb588 --- /dev/null +++ b/src/sentinel_solver.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import importlib.resources +import json +import re +import subprocess +from pathlib import Path +from typing import Any +from urllib.parse import quote + + +SENTINEL_ORIGIN = "https://sentinel.openai.com" +SENTINEL_BOOTSTRAP_URL = f"{SENTINEL_ORIGIN}/backend-api/sentinel/sdk.js" +SDK_VERSION_RE = re.compile(r"script\.src\s*=\s*'https://sentinel\.openai\.com/sentinel/([^']+)/sdk\.js'") + + +class SentinelSolveError(RuntimeError): + pass + + +class SentinelSolver: + def __init__( + self, + http, + *, + node_binary: str = "node", + runner_path: str | Path | None = None, + timeout_seconds: float = 30.0, + ) -> None: + self.http = http + self.node_binary = node_binary + self.runner_path = Path(runner_path) if runner_path else self._default_runner_path() + self.timeout_seconds = timeout_seconds + self._flow_cache: dict[str, dict[str, Any]] = {} + + def build_token(self, flow: str) -> str: + cached = self._prepare_flow(flow) + chat_req = cached["chat_req"] + env = cached["env"] + proof = cached["prepare_proof"] + turnstile = chat_req.get("turnstile") if isinstance(chat_req, dict) else None + dx = turnstile.get("dx") if isinstance(turnstile, dict) else None + encoded_turnstile = None + if dx: + encoded_turnstile = self._run_node("turnstile", {**env, "p": proof, "dx": dx})["t"] + if not isinstance(encoded_turnstile, str) or not encoded_turnstile: + raise SentinelSolveError( + f"sentinel turnstile runner returned invalid result: {encoded_turnstile!r}" + ) + enforcement_proof = self._run_node("enforcement", {**env, "chat_req": chat_req})["p"] + if not isinstance(enforcement_proof, str) or not enforcement_proof: + raise SentinelSolveError( + f"sentinel enforcement runner returned invalid proof: {enforcement_proof!r}" + ) + + token: dict[str, Any] = { + "p": enforcement_proof, + "t": encoded_turnstile, + "c": chat_req.get("token") if isinstance(chat_req, dict) else None, + "flow": flow, + } + did = self._cookie_value("oai-did") or getattr(self.http, "device_id", "") + if did: + token["id"] = did + return json.dumps(token, separators=(",", ":")) + + def build_session_observer_token(self, flow: str) -> str: + cached = self._prepare_flow(flow) + chat_req = cached["chat_req"] + so_payload = chat_req.get("so") if isinstance(chat_req, dict) else None + snapshot_dx = so_payload.get("snapshot_dx") if isinstance(so_payload, dict) else None + if not snapshot_dx: + raise SentinelSolveError(f"sentinel session observer snapshot missing for flow {flow!r}") + + so_value = self._run_node( + "session-observer", + { + **cached["env"], + "flow": flow, + "c": chat_req.get("token") if isinstance(chat_req, dict) else None, + "snapshot_dx": snapshot_dx, + "chat_req": chat_req, + }, + )["so"] + if not isinstance(so_value, str) or not so_value: + raise SentinelSolveError( + f"sentinel session observer runner returned invalid payload: {so_value!r}" + ) + + token: dict[str, Any] = { + "so": so_value, + "c": chat_req.get("token") if isinstance(chat_req, dict) else None, + "flow": flow, + } + did = self._cookie_value("oai-did") or getattr(self.http, "device_id", "") + if did: + token["id"] = did + return json.dumps(token, separators=(",", ":")) + + def _fetch_sdk_version(self) -> str: + response = self.http.request("GET", SENTINEL_BOOTSTRAP_URL) + text = getattr(response, "text", "") or "" + match = SDK_VERSION_RE.search(text) + if not match: + raise SentinelSolveError("failed to parse active sentinel SDK version") + return match.group(1) + + def _fetch_req_payload(self, flow: str, proof: str, sdk_version: str) -> dict[str, Any]: + payload: dict[str, Any] = {"p": proof, "flow": flow} + did = self._cookie_value("oai-did") or getattr(self.http, "device_id", "") + if did: + payload["id"] = did + + frame_url = f"{SENTINEL_ORIGIN}/backend-api/sentinel/frame.html?sv={quote(sdk_version)}" + self._warm_frame(frame_url) + response = self.http.request( + "POST", + f"{SENTINEL_ORIGIN}/backend-api/sentinel/req", + data=json.dumps(payload), + headers={ + "Content-Type": "text/plain;charset=UTF-8", + "Accept": "*/*", + "Origin": SENTINEL_ORIGIN, + "Referer": frame_url, + }, + ) + if getattr(response, "status_code", 0) != 200: + raise SentinelSolveError( + f"sentinel req failed: {getattr(response, 'status_code', 'unknown')} {self._response_excerpt(response)}" + ) + try: + data = response.json() + except Exception as error: # pragma: no cover - defensive + raise SentinelSolveError(f"sentinel req returned non-JSON response: {error}") from error + if not isinstance(data, dict): + raise SentinelSolveError(f"sentinel req returned invalid payload: {data!r}") + return data + + def _prepare_flow(self, flow: str) -> dict[str, Any]: + cached = self._flow_cache.get(flow) + if cached: + return cached + + version = self._fetch_sdk_version() + env = self._node_environment(version) + proof = self._run_node("prepare", env)["p"] + if not isinstance(proof, str) or not proof: + raise SentinelSolveError(f"sentinel prepare returned invalid proof: {proof!r}") + + chat_req = self._fetch_req_payload(flow, proof, version) + cached = { + "sdk_version": version, + "env": env, + "prepare_proof": proof, + "chat_req": chat_req, + } + self._flow_cache[flow] = cached + return cached + + def _node_environment(self, sdk_version: str) -> dict[str, Any]: + sdk_url = f"{SENTINEL_ORIGIN}/sentinel/{sdk_version}/sdk.js" + accept_language = self._session_header("Accept-Language") + return { + "sdk_version": sdk_version, + "sdk_url": sdk_url, + "sdk_bootstrap_url": SENTINEL_BOOTSTRAP_URL, + "sdk_version_url": sdk_url, + "location_search_prepare": f"?sv={sdk_version}", + "location_search_enforcement": "", + "user_agent": self._session_header("User-Agent"), + "accept_language": accept_language, + "hardware_concurrency": 8, + "screen_width": 1920, + "screen_height": 1080, + "js_heap_size_limit": 4294967296, + } + + def _run_node(self, mode: str, payload: dict[str, Any]) -> dict[str, Any]: + command = [self.node_binary, str(self.runner_path), mode] + try: + completed = subprocess.run( + command, + input=json.dumps(payload), + text=True, + capture_output=True, + timeout=self.timeout_seconds, + check=False, + ) + except FileNotFoundError as error: + raise SentinelSolveError(f"Node.js executable not found: {self.node_binary}") from error + except subprocess.TimeoutExpired as error: + raise SentinelSolveError(f"sentinel {mode} timed out") from error + + if completed.returncode != 0: + stderr = (completed.stderr or "").strip() + stdout = (completed.stdout or "").strip() + detail = stderr or stdout or f"exit code {completed.returncode}" + raise SentinelSolveError(f"sentinel {mode} failed: {detail}") + + stdout = (completed.stdout or "").strip() + if not stdout: + raise SentinelSolveError(f"sentinel {mode} returned empty stdout") + try: + data = json.loads(stdout) + except json.JSONDecodeError as error: + raise SentinelSolveError(f"sentinel {mode} returned invalid JSON: {stdout!r}") from error + if not isinstance(data, dict): + raise SentinelSolveError(f"sentinel {mode} returned invalid payload: {data!r}") + return data + + def _cookie_value(self, name: str) -> str: + session = getattr(self.http, "session", None) + cookies = getattr(session, "cookies", None) + jar = getattr(cookies, "jar", None) + if not jar: + return "" + for cookie in jar: + if getattr(cookie, "name", "") == name: + return getattr(cookie, "value", "") + return "" + + def _session_header(self, name: str) -> str: + session = getattr(self.http, "session", None) + headers = getattr(session, "headers", {}) or {} + return headers.get(name, "") + + def _warm_frame(self, frame_url: str) -> None: + response = self.http.request( + "GET", + frame_url, + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Referer": SENTINEL_BOOTSTRAP_URL, + }, + ) + if getattr(response, "status_code", 0) not in {200, 204}: + raise SentinelSolveError( + f"sentinel frame warmup failed: {getattr(response, 'status_code', 'unknown')} {self._response_excerpt(response)}" + ) + + @staticmethod + def _response_excerpt(response, limit: int = 300) -> str: + text = getattr(response, "text", "") or "" + return text.replace("\n", " ").replace("\r", " ")[:limit] + + @staticmethod + def _default_runner_path() -> Path: + try: + return Path(importlib.resources.files(__package__).joinpath("sentinel_runner.js")) + except Exception: + return Path(__file__).with_name("sentinel_runner.js") diff --git a/src/sentinel_vm.js b/src/sentinel_vm.js new file mode 100644 index 0000000..594b1b2 --- /dev/null +++ b/src/sentinel_vm.js @@ -0,0 +1,600 @@ +'use strict'; + +const carrierSecrets = new WeakMap(); + +const OPCODES = Object.freeze({ + RUN_ENCODED_PROGRAM: 0, + XOR_IN_PLACE: 1, + SET_LITERAL: 2, + RESOLVE: 3, + REJECT: 4, + APPEND_OR_ADD: 5, + READ_PROPERTY: 6, + CALL_WITH_REGISTER_ARGS: 7, + COPY_REGISTER: 8, + INSTRUCTION_QUEUE: 9, + WINDOW_OBJECT: 10, + FIND_SCRIPT_SRC_BY_REGEX: 11, + STORE_REGISTER_MAP: 12, + CALL_WITH_RAW_ARGS: 13, + JSON_PARSE: 14, + JSON_STRINGIFY: 15, + SECRET_KEY: 16, + CALL_AND_STORE_RESULT: 17, + BASE64_DECODE_IN_PLACE: 18, + BASE64_ENCODE_IN_PLACE: 19, + CALL_IF_EQUAL: 20, + CALL_IF_DELTA_EXCEEDS: 21, + RUN_BLOCK: 22, + CALL_IF_DEFINED: 23, + BIND_METHOD: 24, + NOOP_25: 25, + NOOP_26: 26, + REMOVE_OR_SUBTRACT: 27, + NOOP_28: 28, + LESS_THAN: 29, + DEFINE_VM_FUNCTION: 30, + MULTIPLY: 33, + AWAIT_VALUE: 34, + DIVIDE: 35, +}); + +function bindSecretToCarrier(carrier, secret) { + if (carrier && (typeof carrier === 'object' || typeof carrier === 'function')) { + carrierSecrets.set(carrier, String(secret ?? '')); + } + return carrier; +} + +function createSecretCarrier(secret) { + return bindSecretToCarrier({}, secret); +} + +function resolveSecretInput(secretInput) { + if (secretInput && (typeof secretInput === 'object' || typeof secretInput === 'function')) { + return carrierSecrets.get(secretInput) ?? ''; + } + return String(secretInput ?? ''); +} + +function xorCipher(text, secret) { + const source = String(text ?? ''); + const key = String(secret ?? ''); + if (key.length === 0) { + return source; + } + + let output = ''; + for (let index = 0; index < source.length; index += 1) { + output += String.fromCharCode( + source.charCodeAt(index) ^ key.charCodeAt(index % key.length), + ); + } + return output; +} + +function defaultAtob(value) { + if (typeof atob === 'function') { + return atob(value); + } + return Buffer.from(String(value), 'base64').toString('binary'); +} + +function defaultBtoa(value) { + if (typeof btoa === 'function') { + return btoa(value); + } + return Buffer.from(String(value), 'binary').toString('base64'); +} + +function isPromiseLike(value) { + return Boolean(value) && typeof value.then === 'function'; +} + +function toErrorText(error) { + return String(error); +} + +function isBase64Like(value, atobImpl, btoaImpl) { + if (typeof value !== 'string' || value.length === 0 || value.length % 4 !== 0) { + return false; + } + if (!/^[A-Za-z0-9+/]+=*$/.test(value)) { + return false; + } + try { + return btoaImpl(atobImpl(value)) === value; + } catch { + return false; + } +} + +class ReadableSssdkInterpreter { + constructor(options = {}) { + this.windowRef = options.windowRef ?? globalThis.window ?? globalThis; + this.documentRef = options.documentRef ?? globalThis.document ?? { scripts: [] }; + this.atob = options.atobImpl ?? defaultAtob; + this.btoa = options.btoaImpl ?? defaultBtoa; + this.defaultTimeoutMs = options.timeoutMs ?? 500; + this.hooks = options.hooks ?? {}; + + this.registers = new Map(); + this.stepCount = 0; + this.executionTail = Promise.resolve(); + } + + static bindSecretToCarrier(carrier, secret) { + return bindSecretToCarrier(carrier, secret); + } + + static createSecretCarrier(secret) { + return createSecretCarrier(secret); + } + + resetMachine(secret) { + this.registers.clear(); + this.stepCount = 0; + + this.registers.set(OPCODES.RUN_ENCODED_PROGRAM, encodedProgram => { + return this._runEncodedProgramLikeOriginalDirect( + encodedProgram, + { secret: String(this.getRegister(OPCODES.SECRET_KEY) ?? '') }, + false, + ); + }); + + this.registers.set(OPCODES.XOR_IN_PLACE, (targetRegister, keyRegister) => { + const currentValue = String(this.getRegister(targetRegister) ?? ''); + const keyValue = String(this.getRegister(keyRegister) ?? ''); + this.setRegister(targetRegister, xorCipher(currentValue, keyValue)); + }); + + this.registers.set(OPCODES.SET_LITERAL, (targetRegister, literalValue) => { + this.setRegister(targetRegister, literalValue); + }); + + this.registers.set(OPCODES.APPEND_OR_ADD, (targetRegister, sourceRegister) => { + const currentValue = this.getRegister(targetRegister); + const sourceValue = this.getRegister(sourceRegister); + + if (Array.isArray(currentValue)) { + currentValue.push(sourceValue); + return; + } + + this.setRegister(targetRegister, currentValue + sourceValue); + }); + + this.registers.set(OPCODES.REMOVE_OR_SUBTRACT, (targetRegister, sourceRegister) => { + const currentValue = this.getRegister(targetRegister); + const sourceValue = this.getRegister(sourceRegister); + + if (Array.isArray(currentValue)) { + const position = currentValue.indexOf(sourceValue); + if (position >= 0) { + currentValue.splice(position, 1); + } + return; + } + + this.setRegister(targetRegister, currentValue - sourceValue); + }); + + this.registers.set(OPCODES.LESS_THAN, (targetRegister, leftRegister, rightRegister) => { + this.setRegister( + targetRegister, + this.getRegister(leftRegister) < this.getRegister(rightRegister), + ); + }); + + this.registers.set(OPCODES.MULTIPLY, (targetRegister, leftRegister, rightRegister) => { + const leftValue = Number(this.getRegister(leftRegister)); + const rightValue = Number(this.getRegister(rightRegister)); + this.setRegister(targetRegister, leftValue * rightValue); + }); + + this.registers.set(OPCODES.DIVIDE, (targetRegister, leftRegister, rightRegister) => { + const leftValue = Number(this.getRegister(leftRegister)); + const rightValue = Number(this.getRegister(rightRegister)); + this.setRegister(targetRegister, rightValue === 0 ? 0 : leftValue / rightValue); + }); + + this.registers.set(OPCODES.READ_PROPERTY, (targetRegister, objectRegister, keyRegister) => { + const objectValue = this.getRegister(objectRegister); + const propertyKey = this.getRegister(keyRegister); + this.setRegister(targetRegister, objectValue[propertyKey]); + }); + + this.registers.set(OPCODES.CALL_WITH_REGISTER_ARGS, (functionRegister, ...argumentRegisters) => { + const callable = this.getRegister(functionRegister); + const resolvedArgs = argumentRegisters.map(registerId => this.getRegister(registerId)); + return callable(...resolvedArgs); + }); + + this.registers.set( + OPCODES.CALL_AND_STORE_RESULT, + (targetRegister, functionRegister, ...argumentRegisters) => { + try { + const callable = this.getRegister(functionRegister); + const resolvedArgs = argumentRegisters.map(registerId => this.getRegister(registerId)); + const returnValue = callable(...resolvedArgs); + + if (isPromiseLike(returnValue)) { + return returnValue + .then(value => { + this.setRegister(targetRegister, value); + }) + .catch(error => { + this.setRegister(targetRegister, toErrorText(error)); + }); + } + + this.setRegister(targetRegister, returnValue); + } catch (error) { + this.setRegister(targetRegister, toErrorText(error)); + } + }, + ); + + this.registers.set(OPCODES.CALL_WITH_RAW_ARGS, (targetRegister, functionRegister, ...rawArgs) => { + try { + const callable = this.getRegister(functionRegister); + callable(...rawArgs); + } catch (error) { + this.setRegister(targetRegister, toErrorText(error)); + } + }); + + this.registers.set(OPCODES.COPY_REGISTER, (targetRegister, sourceRegister) => { + this.setRegister(targetRegister, this.getRegister(sourceRegister)); + }); + + this.registers.set(OPCODES.WINDOW_OBJECT, this.windowRef); + + this.registers.set(OPCODES.FIND_SCRIPT_SRC_BY_REGEX, (targetRegister, regexRegister) => { + const regex = this.getRegister(regexRegister); + const scripts = Array.from(this.documentRef.scripts || []); + const firstMatch = scripts + .map(script => script?.src?.match?.(regex)) + .filter(Boolean)[0]; + + this.setRegister(targetRegister, (firstMatch ?? [])[0] ?? null); + }); + + this.registers.set(OPCODES.STORE_REGISTER_MAP, targetRegister => { + this.setRegister(targetRegister, this.registers); + }); + + this.registers.set(OPCODES.JSON_PARSE, (targetRegister, sourceRegister) => { + this.setRegister(targetRegister, JSON.parse(String(this.getRegister(sourceRegister)))); + }); + + this.registers.set(OPCODES.JSON_STRINGIFY, (targetRegister, sourceRegister) => { + this.setRegister(targetRegister, JSON.stringify(this.getRegister(sourceRegister))); + }); + + this.registers.set(OPCODES.BASE64_DECODE_IN_PLACE, targetRegister => { + this.setRegister(targetRegister, this.atob(String(this.getRegister(targetRegister)))); + }); + + this.registers.set(OPCODES.BASE64_ENCODE_IN_PLACE, targetRegister => { + this.setRegister(targetRegister, this.btoa(String(this.getRegister(targetRegister)))); + }); + + this.registers.set( + OPCODES.CALL_IF_EQUAL, + (leftRegister, rightRegister, functionRegister, ...rawArgs) => { + if (this.getRegister(leftRegister) === this.getRegister(rightRegister)) { + return this.getRegister(functionRegister)(...rawArgs); + } + return null; + }, + ); + + this.registers.set( + OPCODES.CALL_IF_DELTA_EXCEEDS, + (leftRegister, rightRegister, thresholdRegister, functionRegister, ...rawArgs) => { + const delta = Math.abs(this.getRegister(leftRegister) - this.getRegister(rightRegister)); + if (delta > this.getRegister(thresholdRegister)) { + return this.getRegister(functionRegister)(...rawArgs); + } + return null; + }, + ); + + this.registers.set(OPCODES.CALL_IF_DEFINED, (guardRegister, functionRegister, ...rawArgs) => { + if (this.getRegister(guardRegister) !== undefined) { + return this.getRegister(functionRegister)(...rawArgs); + } + return null; + }); + + this.registers.set(OPCODES.BIND_METHOD, (targetRegister, objectRegister, keyRegister) => { + const objectValue = this.getRegister(objectRegister); + const propertyKey = this.getRegister(keyRegister); + this.setRegister(targetRegister, objectValue[propertyKey].bind(objectValue)); + }); + + this.registers.set(OPCODES.AWAIT_VALUE, (targetRegister, sourceRegister) => { + try { + return Promise.resolve(this.getRegister(sourceRegister)).then(value => { + this.setRegister(targetRegister, value); + }); + } catch { + return undefined; + } + }); + + this.registers.set(OPCODES.RUN_BLOCK, (targetRegister, nestedInstructions) => { + const previousQueue = this.getQueueSnapshot(); + this.setQueue(this.cloneInstructionQueue(nestedInstructions)); + + return this.executeCurrentQueue() + .catch(error => { + this.setRegister(targetRegister, toErrorText(error)); + }) + .finally(() => { + this.setQueue(previousQueue); + }); + }); + + this.registers.set(OPCODES.NOOP_25, () => {}); + this.registers.set(OPCODES.NOOP_26, () => {}); + this.registers.set(OPCODES.NOOP_28, () => {}); + + this.setRegister(OPCODES.SECRET_KEY, String(secret ?? '')); + } + + getRegister(registerId) { + return this.registers.get(registerId); + } + + setRegister(registerId, value) { + if (typeof this.hooks.onSetRegister === 'function') { + this.hooks.onSetRegister({ + registerId, + value, + stepCount: this.stepCount, + }); + } + this.registers.set(registerId, value); + } + + getQueueSnapshot() { + return [...(this.getRegister(OPCODES.INSTRUCTION_QUEUE) ?? [])]; + } + + setQueue(queue) { + this.setRegister(OPCODES.INSTRUCTION_QUEUE, queue); + } + + cloneInstructionQueue(queue) { + return Array.isArray(queue) ? [...queue] : []; + } + + resolveSecretInput(secretInput) { + return resolveSecretInput(secretInput); + } + + decodeProgram(encodedProgram, secret) { + const binary = this.atob(String(encodedProgram)); + const jsonText = xorCipher(binary, secret); + return JSON.parse(jsonText); + } + + queueExclusive(task) { + const next = this.executionTail.then(task, task); + this.executionTail = next.then( + () => undefined, + () => undefined, + ); + return next; + } + + withRunScope(scopeState, executor) { + const previousResolve = this.getRegister(OPCODES.RESOLVE); + const previousReject = this.getRegister(OPCODES.REJECT); + const previousDefineFunction = this.getRegister(OPCODES.DEFINE_VM_FUNCTION); + + this.setRegister(OPCODES.RESOLVE, rawValue => { + scopeState.resolveWire(rawValue); + }); + + this.setRegister(OPCODES.REJECT, rawValue => { + scopeState.rejectWire(rawValue); + }); + + this.setRegister( + OPCODES.DEFINE_VM_FUNCTION, + (targetRegister, returnRegister, parameterRegistersOrProgram, maybeProgram) => { + const hasParameterRegisters = Array.isArray(maybeProgram); + const parameterRegisters = hasParameterRegisters ? parameterRegistersOrProgram : []; + const functionProgram = (hasParameterRegisters ? maybeProgram : parameterRegistersOrProgram) || []; + + this.setRegister(targetRegister, (...runtimeArgs) => { + if (scopeState.isSettled()) { + return undefined; + } + + const previousQueue = this.getQueueSnapshot(); + + if (hasParameterRegisters) { + for (let index = 0; index < parameterRegisters.length; index += 1) { + this.setRegister(parameterRegisters[index], runtimeArgs[index]); + } + } + + this.setQueue(this.cloneInstructionQueue(functionProgram)); + + return this.executeCurrentQueue() + .then(() => this.getRegister(returnRegister)) + .catch(error => toErrorText(error)) + .finally(() => { + this.setQueue(previousQueue); + }); + }); + }, + ); + + return Promise.resolve() + .then(executor) + .finally(() => { + this.setRegister(OPCODES.RESOLVE, previousResolve); + this.setRegister(OPCODES.REJECT, previousReject); + this.setRegister(OPCODES.DEFINE_VM_FUNCTION, previousDefineFunction); + }); + } + + async executeCurrentQueue() { + while (this.getQueueSnapshot().length > 0) { + const instruction = this.getRegister(OPCODES.INSTRUCTION_QUEUE).shift(); + const [opcode, ...args] = instruction; + const handler = this.getRegister(opcode); + + if (typeof handler !== 'function') { + throw new Error(`Unknown opcode: ${opcode}`); + } + + const maybePromise = handler(...args); + if (isPromiseLike(maybePromise)) { + await maybePromise; + } + + this.stepCount += 1; + } + } + + runEncodedProgramLikeOriginal(encodedProgram, options = {}) { + return this.queueExclusive(() => { + return this._runEncodedProgramLikeOriginalDirect(encodedProgram, options, true); + }); + } + + _runEncodedProgramLikeOriginalDirect(encodedProgram, options = {}, resetMachine = true) { + const secret = String(options.secret ?? ''); + const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs; + + if (resetMachine) { + this.resetMachine(secret); + } + + try { + return this.executeQueueLikeOriginal( + this.decodeProgram(encodedProgram, secret), + timeoutMs, + ); + } catch (error) { + return Promise.resolve(this.btoa(`${this.stepCount}: ${toErrorText(error)}`)); + } + } + + executeQueueLikeOriginal(instructions, timeoutMs) { + const previousQueue = this.getQueueSnapshot(); + + return new Promise((resolve, reject) => { + let settled = false; + + const finishResolve = wireValue => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(wireValue); + }; + + const finishReject = wireValue => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(wireValue); + }; + + const timer = setTimeout(() => { + finishResolve(String(this.stepCount)); + }, timeoutMs); + + const scopeState = { + isSettled: () => settled, + resolveWire: rawValue => { + finishResolve(this.btoa(String(rawValue))); + }, + rejectWire: rawValue => { + finishReject(this.btoa(String(rawValue))); + }, + }; + + this.withRunScope(scopeState, async () => { + this.setQueue(this.cloneInstructionQueue(instructions)); + + try { + await this.executeCurrentQueue(); + } catch (error) { + finishResolve(this.btoa(`${this.stepCount}: ${toErrorText(error)}`)); + } finally { + this.setQueue(previousQueue); + } + }).catch(error => { + finishResolve(this.btoa(`${this.stepCount}: ${toErrorText(error)}`)); + this.setQueue(previousQueue); + }); + }); + } + + async runFromInputs(secretInput, encodedPayload, options = {}) { + const secret = this.resolveSecretInput(secretInput); + return this.normalizeLikeOriginalResult( + () => this.runEncodedProgramLikeOriginal(encodedPayload, { + ...options, + secret, + }), + ); + } + + normalizeLikeOriginalResult(runLikeOriginal) { + return Promise.resolve() + .then(async () => { + try { + const wireValue = await runLikeOriginal(); + return this.normalizeWireValue(wireValue, false); + } catch (wireValue) { + return this.normalizeWireValue(wireValue, true); + } + }); + } + + normalizeWireValue(wireValue, rejected) { + if (!isBase64Like(wireValue, this.atob, this.btoa)) { + return { + channel: 'timeout', + encodedValue: null, + value: String(wireValue), + stepCount: Number(wireValue), + }; + } + + return { + channel: rejected ? 'reject' : 'resolve', + encodedValue: wireValue, + value: this.atob(wireValue), + stepCount: this.stepCount, + }; + } +} + +async function runFromInputs(secretInput, encodedPayload, options = {}) { + const interpreter = new ReadableSssdkInterpreter(options); + return interpreter.runFromInputs(secretInput, encodedPayload, options); +} + +module.exports = { + OPCODES, + ReadableSssdkInterpreter, + bindSecretToCarrier, + createSecretCarrier, + resolveSecretInput, + runFromInputs, + xorCipher, +}; diff --git a/src/vmail_client.py b/src/vmail_client.py index 3d2f215..9979e36 100644 --- a/src/vmail_client.py +++ b/src/vmail_client.py @@ -265,11 +265,16 @@ class YYDSMailClient(BaseMailClient): def create_mailbox(self) -> dict: """Create a mailbox. Returns dict with id, address, token.""" + payload = {} + preferred_domain = settings.yyds_mail_domain.strip() + if preferred_domain: + payload["domain"] = preferred_domain + for attempt in range(10): r = httpx.post( f"{self.base}/v1/accounts", headers=self.headers, - json={}, + json=payload, timeout=30, ) if r.status_code == 201 or r.status_code == 200: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_register_live_sentinel.py b/tests/test_register_live_sentinel.py new file mode 100644 index 0000000..ec2f96f --- /dev/null +++ b/tests/test_register_live_sentinel.py @@ -0,0 +1,129 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + + +class FakeResponse: + def __init__(self, *, status_code=200, text="", json_data=None, headers=None): + self.status_code = status_code + self.text = text + self._json_data = json_data + self.headers = headers or {"content-type": "application/json"} + + def json(self): + return self._json_data + + +class RegisterLiveSentinelTests(unittest.TestCase): + def test_register_authorize_continue_and_so_token_are_used(self): + from src.chatgpt_register_http_reverse import ChatGPTRegisterHTTPReverse + + flow_history: list[str] = [] + def build_token(flow: str) -> str: + flow_history.append(flow) + return f"{flow}-token" + + def build_session_observer_token(flow: str) -> str: + flow_history.append("session_observer") + return "session-observer-token" + + fake_http = SimpleNamespace( + request=Mock(return_value=FakeResponse(json_data={"accessToken": "at", "user": {"id": "uid"}})), + session=SimpleNamespace(cookies=SimpleNamespace(jar=[])), + ) + fake_mail = SimpleNamespace( + create_mailbox=Mock(return_value={"address": "user@example.com", "id": "box-1", "password": ""}), + wait_for_otp=Mock(return_value="123456"), + ) + solver = SimpleNamespace( + build_token=Mock(side_effect=build_token), + build_session_observer_token=Mock(side_effect=build_session_observer_token), + ) + + register = ChatGPTRegisterHTTPReverse(fake_http, fake_mail, sentinel_solver=solver) + + order: list[str] = [] + create_call_args: dict[str, str | None] = {} + + def fake_authorize_continue(*args, **kwargs): + order.append("authorize_continue") + return "https://auth.openai.com/create-account/password" + + def fake_attempt_register(*args, **kwargs): + order.append("attempt_register") + return FakeResponse(status_code=200), {"continue_url": "continue-1"} + + def fake_attempt_create_account(*args, sentinel=None, sentinel_so=None, **kwargs): + order.append("attempt_create") + create_call_args["sentinel"] = sentinel + create_call_args["sentinel_so"] = sentinel_so + return FakeResponse(status_code=200), {"continue_url": "continue-3"} + + with patch.object(register, "_bootstrap_chatgpt", return_value="csrf"), \ + patch.object(register, "_signin_auth0", return_value="auth-url"), \ + patch.object(register, "_follow_auth_redirects", return_value="https://auth.openai.com/create-account/password"), \ + patch.object(register, "_accounts_api_base", return_value="https://auth.openai.com/api/accounts"), \ + patch.object(register, "_register_endpoint", return_value="https://auth.openai.com/api/accounts/user/register"), \ + patch.object(register, "_authorize_continue", side_effect=fake_authorize_continue, create=True) as authorize_continue, \ + patch.object(register, "_open_continue_url", side_effect=["email-url", "about-you-url"]), \ + patch.object(register, "_validate_otp", return_value="continue-2"), \ + patch.object(register, "_finalize_auth_callback"), \ + patch.object(register, "_attempt_register", side_effect=fake_attempt_register) as attempt_register, \ + patch.object(register, "_attempt_create_account", side_effect=fake_attempt_create_account) as attempt_create: + result = register.register("Passw0rd!", "Jane Doe") + + self.assertEqual(result["email"], "user@example.com") + self.assertEqual( + flow_history, + [ + "authorize_continue", + "username_password_create", + "oauth_create_account", + "session_observer", + ], + ) + self.assertTrue(authorize_continue.called) + self.assertEqual(authorize_continue.call_args.args[1], "https://auth.openai.com/log-in-or-create-account?usernameKind=email") + self.assertLess(order.index("authorize_continue"), order.index("attempt_register")) + self.assertEqual(attempt_register.call_args.args[1], "https://auth.openai.com/create-account/password") + self.assertEqual(attempt_register.call_args.kwargs["sentinel"], "username_password_create-token") + self.assertEqual(create_call_args["sentinel"], "oauth_create_account-token") + self.assertEqual(create_call_args["sentinel_so"], "session-observer-token") + solver.build_session_observer_token.assert_called_once_with("oauth_create_account") + + + def test_authorize_continue_posts_screen_hint_and_json_headers(self): + from src.chatgpt_register_http_reverse import ChatGPTRegisterHTTPReverse + + captured = {} + + def fake_request(method, url, **kwargs): + captured['method'] = method + captured['url'] = url + captured['kwargs'] = kwargs + return FakeResponse(status_code=200, json_data={"continue_url": "https://auth.openai.com/create-account/password"}) + + fake_http = SimpleNamespace( + request=fake_request, + session=SimpleNamespace(cookies=SimpleNamespace(jar=[])), + ) + fake_mail = SimpleNamespace() + register = ChatGPTRegisterHTTPReverse(fake_http, fake_mail, sentinel_solver=SimpleNamespace()) + + with patch.object(register, '_open_continue_url', return_value='https://auth.openai.com/create-account/password'): + result = register._authorize_continue( + 'https://auth.openai.com/api/accounts', + 'https://auth.openai.com/log-in-or-create-account?usernameKind=email', + 'user@example.com', + sentinel='authorize-token', + ) + + self.assertEqual(result, 'https://auth.openai.com/create-account/password') + self.assertEqual(captured['method'], 'POST') + self.assertEqual(captured['kwargs']['json']['screen_hint'], 'login_or_signup') + self.assertEqual(captured['kwargs']['headers']['Accept'], 'application/json') + self.assertEqual(captured['kwargs']['headers']['openai-sentinel-token'], 'authorize-token') + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sentinel_solver.py b/tests/test_sentinel_solver.py new file mode 100644 index 0000000..9d59d7e --- /dev/null +++ b/tests/test_sentinel_solver.py @@ -0,0 +1,146 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import patch + + +class FakeResponse: + def __init__(self, *, text="", status_code=200, json_data=None, headers=None): + self.text = text + self.status_code = status_code + self._json_data = json_data + self.headers = headers or {} + + def json(self): + if self._json_data is None: + raise ValueError("no json") + return self._json_data + + +class FakeHTTP: + def __init__(self): + self.calls = [] + self.device_id = "device-fallback-id" + self.session = SimpleNamespace( + headers={ + "User-Agent": "Mozilla/5.0 Test", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + }, + cookies=SimpleNamespace( + jar=[SimpleNamespace(name="oai-did", value="cookie-did", domain="chatgpt.com")] + ), + ) + self.req_payloads = [] + + def request(self, method, url, **kwargs): + self.calls.append((method, url, kwargs)) + if url.endswith("/backend-api/sentinel/sdk.js"): + return FakeResponse( + text=( + "window.SentinelSDK = {};" + "script.src = 'https://sentinel.openai.com/sentinel/20260219f9f6/sdk.js';" + ) + ) + if "/backend-api/sentinel/frame.html?sv=" in url: + return FakeResponse(text="") + if url.endswith("/backend-api/sentinel/req"): + data = kwargs.get("data") + if data: + payload = json.loads(data) + else: + payload = {} + self.req_payloads.append(payload) + return FakeResponse( + json_data={ + "token": "collector-token", + "proofofwork": {"required": True, "seed": "seed-1", "difficulty": "0"}, + "turnstile": {"required": True, "dx": "encoded-dx"}, + "so": {"required": True, "snapshot_dx": "snapshot-dx"}, + } + ) + raise AssertionError(f"unexpected request: {method} {url}") + + +class SentinelSolverTests(unittest.TestCase): + @patch("subprocess.run") + def test_build_token_uses_enforcement_p_not_prepare_p(self, run_mock): + from src.sentinel_solver import SentinelSolver + + def fake_run(command, **kwargs): + mode = command[-1] + payload = json.loads(kwargs["input"]) + if mode == "prepare": + return SimpleNamespace(returncode=0, stdout=json.dumps({"p": "prepare-proof"}), stderr="") + if mode == "turnstile": + self.assertEqual(payload["p"], "prepare-proof") + self.assertEqual(payload["dx"], "encoded-dx") + return SimpleNamespace(returncode=0, stdout=json.dumps({"t": "wire-turnstile"}), stderr="") + if mode == "enforcement": + self.assertEqual(payload["chat_req"]["proofofwork"]["seed"], "seed-1") + return SimpleNamespace(returncode=0, stdout=json.dumps({"p": "enforcement-proof"}), stderr="") + raise AssertionError(command) + + run_mock.side_effect = fake_run + http = FakeHTTP() + solver = SentinelSolver(http) + + token = solver.build_token("username_password_create") + parsed = json.loads(token) + + self.assertEqual( + parsed, + { + "p": "enforcement-proof", + "t": "wire-turnstile", + "c": "collector-token", + "id": "cookie-did", + "flow": "username_password_create", + }, + ) + self.assertEqual(http.calls[0][0:2], ("GET", "https://sentinel.openai.com/backend-api/sentinel/sdk.js")) + self.assertTrue(http.calls[1][1].startswith("https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=")) + self.assertEqual(http.calls[2][0:2], ("POST", "https://sentinel.openai.com/backend-api/sentinel/req")) + self.assertEqual(http.req_payloads[-1]["p"], "prepare-proof") + self.assertEqual(http.req_payloads[-1]["flow"], "username_password_create") + + @patch("subprocess.run") + def test_build_session_observer_token_uses_cached_chat_req_snapshot(self, run_mock): + from src.sentinel_solver import SentinelSolver + + def fake_run(command, **kwargs): + mode = command[-1] + payload = json.loads(kwargs["input"]) + if mode == "prepare": + return SimpleNamespace(returncode=0, stdout=json.dumps({"p": "prepare-proof"}), stderr="") + if mode == "turnstile": + return SimpleNamespace(returncode=0, stdout=json.dumps({"t": "wire-turnstile"}), stderr="") + if mode == "enforcement": + return SimpleNamespace(returncode=0, stdout=json.dumps({"p": "enforcement-proof"}), stderr="") + if mode == "session-observer": + self.assertEqual(payload["flow"], "oauth_create_account") + self.assertEqual(payload["c"], "collector-token") + self.assertEqual(payload["snapshot_dx"], "snapshot-dx") + return SimpleNamespace(returncode=0, stdout=json.dumps({"so": "wire-so"}), stderr="") + raise AssertionError(command) + + run_mock.side_effect = fake_run + http = FakeHTTP() + solver = SentinelSolver(http) + + so_token = solver.build_session_observer_token("oauth_create_account") + + self.assertEqual( + json.loads(so_token), + { + "so": "wire-so", + "c": "collector-token", + "id": "cookie-did", + "flow": "oauth_create_account", + }, + ) + self.assertEqual(http.req_payloads[-1]["flow"], "oauth_create_account") + self.assertEqual(http.req_payloads[-1]["p"], "prepare-proof") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_yyds_mail_client.py b/tests/test_yyds_mail_client.py new file mode 100644 index 0000000..d79b438 --- /dev/null +++ b/tests/test_yyds_mail_client.py @@ -0,0 +1,40 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import patch + + +class YYDSMailClientDomainTests(unittest.TestCase): + @patch('src.vmail_client.httpx.post') + def test_create_mailbox_sends_configured_domain(self, post_mock): + from src.vmail_client import YYDSMailClient, settings + + captured = {} + + def fake_post(url, headers=None, json=None, timeout=None): + captured['url'] = url + captured['headers'] = headers + captured['json'] = json + captured['timeout'] = timeout + return SimpleNamespace( + status_code=201, + json=lambda: { + 'data': { + 'id': 'box-1', + 'address': 'chosen@20001028.xyz', + 'token': 'temp-token', + } + }, + ) + + post_mock.side_effect = fake_post + + with patch.object(settings, 'yyds_mail_domain', '20001028.xyz', create=True): + client = YYDSMailClient() + mailbox = client.create_mailbox() + + self.assertEqual(mailbox['address'], 'chosen@20001028.xyz') + self.assertEqual(captured['json'], {'domain': '20001028.xyz'}) + + +if __name__ == '__main__': + unittest.main()