deepwrap 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ from .pow_asm import ProofOfWorkSolver, PowChallenge
2
+
3
+ __all__ = ["ProofOfWorkSolver", "PowChallenge"]
@@ -0,0 +1,305 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import struct
5
+ import urllib.request
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+ from wasmtime import Instance, Store
11
+ from deepwrap.config import Config
12
+
13
+ @dataclass(frozen=True)
14
+ class PowChallenge:
15
+ """
16
+ Represents a DeepSeek proof-of-work challenge.
17
+
18
+ This object contains all server-provided fields required to solve the
19
+ challenge and build the `x-ds-pow-response` header.
20
+
21
+ Attributes:
22
+ algorithm:
23
+ The PoW algorithm identifier returned by the API, such as
24
+ "DeepSeekHashV1".
25
+
26
+ challenge:
27
+ The hex-encoded challenge string passed to the WASM solver.
28
+
29
+ salt:
30
+ The server-provided salt used to build the solver prefix.
31
+
32
+ signature:
33
+ The server-provided signature that must be echoed back unchanged
34
+ in the PoW response payload.
35
+
36
+ difficulty:
37
+ The difficulty value passed to the WASM solver.
38
+
39
+ expire_at:
40
+ Challenge expiration timestamp in Unix milliseconds.
41
+
42
+ expire_after:
43
+ Challenge lifetime in milliseconds.
44
+
45
+ target_path:
46
+ The API path this challenge was issued for, such as
47
+ "/api/v0/chat/completion".
48
+ """
49
+
50
+ algorithm: str
51
+ challenge: str
52
+ salt: str
53
+ signature: str
54
+ difficulty: int
55
+ expire_at: int
56
+ expire_after: int
57
+ target_path: str
58
+
59
+
60
+ class ProofOfWorkSolver:
61
+ """
62
+ Solves DeepSeek proof-of-work challenges using the browser-compatible WASM module.
63
+
64
+ Browser worker behavior:
65
+ prefix = f"{salt}_{expireAt}_"
66
+ wasm_solve(ret_ptr, challenge_ptr, challenge_len, prefix_ptr, prefix_len, difficulty)
67
+ ok = getInt32(ret_ptr + 0, little_endian=True)
68
+ answer = getFloat64(ret_ptr + 8, little_endian=True)
69
+
70
+ Notes:
71
+ - The second string passed into the solver is the prefix, not the raw salt.
72
+ - The final numeric argument is `difficulty` as float64.
73
+ - `expire_at` is used only when building the prefix string.
74
+ """
75
+
76
+ def __init__(self, wasm_path: Optional[str] = None) -> None:
77
+ """
78
+ Initialize the solver.
79
+
80
+ Args:
81
+ wasm_path:
82
+ Optional local path to the PoW WASM binary. If omitted, the WASM
83
+ module is downloaded from the configured URL.
84
+ """
85
+
86
+ self._wasm_path = wasm_path
87
+ self._wasm_bytes: Optional[bytes] = None
88
+ self._engine = None
89
+ self._module = None
90
+
91
+ def _load_bytes(self) -> bytes:
92
+ """
93
+ Load the WASM module bytes.
94
+
95
+ The bytes are loaded once and cached in memory. If `wasm_path` points
96
+ to an existing file, that file is used. Otherwise, the module is
97
+ downloaded from `Config.wasm_url`.
98
+
99
+ Returns:
100
+ The raw WASM bytes.
101
+ """
102
+
103
+ if self._wasm_bytes is not None:
104
+ return self._wasm_bytes
105
+
106
+ if self._wasm_path and os.path.isfile(self._wasm_path):
107
+ with open(self._wasm_path, "rb") as fh:
108
+ self._wasm_bytes = fh.read()
109
+ return self._wasm_bytes
110
+
111
+ with urllib.request.urlopen(Config.wasm_url, timeout=20) as resp:
112
+ self._wasm_bytes = resp.read()
113
+
114
+ return self._wasm_bytes
115
+
116
+ def _write(self, ptr: int, data: bytes, memory, store) -> None:
117
+ """
118
+ Write bytes into WASM linear memory.
119
+
120
+ Args:
121
+ ptr:
122
+ Destination pointer inside WASM memory.
123
+ data:
124
+ Bytes to write.
125
+ memory:
126
+ Exported WASM memory object.
127
+ store:
128
+ The active WASM store.
129
+ """
130
+
131
+ raw = memory.data_ptr(store)
132
+
133
+ for i, b in enumerate(data):
134
+ raw[ptr + i] = b
135
+
136
+ def _read(self, ptr: int, size: int, memory, store) -> bytes:
137
+ """
138
+ Read bytes from WASM linear memory.
139
+
140
+ Args:
141
+ ptr:
142
+ Source pointer inside WASM memory.
143
+ size:
144
+ Number of bytes to read.
145
+ memory:
146
+ Exported WASM memory object.
147
+ store:
148
+ The active WASM store.
149
+
150
+ Returns:
151
+ The bytes read from WASM memory.
152
+ """
153
+
154
+ return bytes(memory.data_ptr(store)[ptr + i] for i in range(size))
155
+
156
+ def _pass_string(self, value: str, malloc, realloc, memory, store) -> tuple[int, int]:
157
+ """
158
+ Copy a Python string into WASM memory.
159
+
160
+ This mirrors the wasm-bindgen string-passing behavior used by the
161
+ browser worker. ASCII-only strings use a fast path; non-ASCII strings
162
+ fall back to reallocation-based UTF-8 encoding.
163
+
164
+ Args:
165
+ value:
166
+ The Python string to copy.
167
+ malloc:
168
+ WASM malloc export.
169
+ realloc:
170
+ WASM realloc export.
171
+ memory:
172
+ Exported WASM memory object.
173
+ store:
174
+ The active WASM store.
175
+
176
+ Returns:
177
+ A tuple of `(ptr, length)` describing the copied UTF-8 string in
178
+ WASM memory.
179
+ """
180
+
181
+ encoded = value.encode("utf-8")
182
+
183
+ if all(b < 128 for b in encoded):
184
+ ptr = malloc(store, len(encoded), 1)
185
+ self._write(ptr, encoded, memory, store)
186
+
187
+ return ptr, len(encoded)
188
+
189
+ ptr = malloc(store, len(value), 1)
190
+ raw = memory.data_ptr(store)
191
+ offset = 0
192
+
193
+ while offset < len(value):
194
+ ch = ord(value[offset])
195
+
196
+ if ch > 127:
197
+ break
198
+
199
+ raw[ptr + offset] = ch
200
+ offset += 1
201
+
202
+ if offset != len(value):
203
+ remainder = value[offset:].encode("utf-8")
204
+ ptr = realloc(store, ptr, len(value), offset + len(remainder), 1)
205
+ self._write(ptr + offset, remainder, memory, store)
206
+
207
+ return ptr, offset + len(remainder)
208
+
209
+ return ptr, offset
210
+
211
+ def warmup(self) -> None:
212
+ """
213
+ Preload and compile the WASM module.
214
+
215
+ This reduces latency for the first challenge solve by downloading and
216
+ compiling the module ahead of time. Repeated calls are no-ops.
217
+
218
+ Raises:
219
+ RuntimeError:
220
+ If the `wasmtime` dependency is missing.
221
+ """
222
+
223
+ if self._module is not None:
224
+ return
225
+
226
+ try:
227
+ from wasmtime import Engine, Module
228
+
229
+ except ImportError as exc:
230
+ raise RuntimeError(
231
+ "Missing dependency: wasmtime. Install it with: pip install wasmtime"
232
+ ) from exc
233
+
234
+ self._engine = Engine()
235
+ self._module = Module(self._engine, self._load_bytes())
236
+
237
+ def solve(self, challenge: PowChallenge) -> int:
238
+ """
239
+ Solve a DeepSeek proof-of-work challenge.
240
+
241
+ Args:
242
+ challenge:
243
+ The server-issued proof-of-work challenge.
244
+
245
+ Returns:
246
+ The integer nonce that satisfies the challenge.
247
+
248
+ Raises:
249
+ RuntimeError:
250
+ If the WASM solver reports that no valid solution was found.
251
+ """
252
+
253
+ if self._module is None:
254
+ self.warmup()
255
+
256
+ store = Store(self._engine)
257
+ instance = Instance(store, self._module, [])
258
+ exports = instance.exports(store)
259
+
260
+ memory = exports["memory"]
261
+ malloc = exports["__wbindgen_export_0"]
262
+ realloc = exports["__wbindgen_export_1"]
263
+ stack_ptr_fn = exports["__wbindgen_add_to_stack_pointer"]
264
+ wasm_solve = exports["wasm_solve"]
265
+
266
+ challenge_ptr, challenge_len = self._pass_string(
267
+ challenge.challenge, malloc, realloc, memory, store
268
+ )
269
+
270
+ prefix_ptr, prefix_len = self._pass_string(
271
+ f"{challenge.salt}_{challenge.expire_at}_",
272
+ malloc,
273
+ realloc,
274
+ memory,
275
+ store
276
+ )
277
+
278
+ ret_ptr = stack_ptr_fn(store, -16)
279
+ try:
280
+ wasm_solve(
281
+ store,
282
+ ret_ptr,
283
+ challenge_ptr,
284
+ challenge_len,
285
+ prefix_ptr,
286
+ prefix_len,
287
+ float(challenge.difficulty),
288
+ )
289
+
290
+ raw = self._read(ret_ptr, 16, memory, store)
291
+ ok = struct.unpack_from("<i", raw, 0)[0]
292
+ answer = struct.unpack_from("<d", raw, 8)[0]
293
+
294
+ finally:
295
+ stack_ptr_fn(store, 16)
296
+
297
+ if ok == 0:
298
+ raise RuntimeError(
299
+ "No solution found: "
300
+ f"algorithm={challenge.algorithm}, "
301
+ f"challenge={challenge.challenge}, "
302
+ f"difficulty={challenge.difficulty}, "
303
+ )
304
+
305
+ return int(answer)
deepwrap/py.typed ADDED
File without changes
@@ -0,0 +1,56 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ class BearerTokenExtractor:
4
+ """
5
+ Utility class to extract bearer tokens from CDP messages.
6
+ """
7
+
8
+ @staticmethod
9
+ def from_headers(headers: Optional[Dict[str, Any]]) -> Optional[str]:
10
+ """
11
+ Extract a bearer token from the given headers dictionary.
12
+
13
+ Args:
14
+ headers (Optional[Dict[str, Any]]): A dictionary of HTTP headers.
15
+
16
+ Returns:
17
+ Optional[str]: The extracted bearer token if found, otherwise None.
18
+ """
19
+
20
+ if not headers:
21
+ return None
22
+
23
+ for key, value in headers.items():
24
+ if key.lower() != "authorization":
25
+ continue
26
+
27
+ value = str(value)
28
+
29
+ if value.lower().startswith("bearer "):
30
+ return value.split(" ", 1)[1]
31
+
32
+ return None
33
+
34
+ @classmethod
35
+ def from_cdp_message(cls, message: Dict[str, Any]) -> Optional[str]:
36
+ """
37
+ Extract a bearer token from a CDP message if it contains relevant network request information.
38
+
39
+ Args:
40
+ message (Dict[str, Any]): A CDP message represented as a dictionary.
41
+
42
+ Returns:
43
+ Optional[str]: The extracted bearer token if found, otherwise None.
44
+ """
45
+
46
+ method = message.get("method")
47
+ params = message.get("params", {})
48
+
49
+ if method == "Network.requestWillBeSent":
50
+ request = params.get("request", {})
51
+ return cls.from_headers(request.get("headers", {}))
52
+
53
+ if method == "Network.requestWillBeSentExtraInfo":
54
+ return cls.from_headers(params.get("headers", {}))
55
+
56
+ return None
@@ -0,0 +1,172 @@
1
+ import os
2
+ import sys
3
+
4
+ from pathlib import Path
5
+ from shutil import which
6
+ from typing import Any, List
7
+
8
+ class BrowserFinder:
9
+ """
10
+ Utility class to find installed Chromium-based browsers on the system.
11
+ This is used by the authentication module to launch a browser instance for user login when no active session is found.
12
+ The finder checks common installation paths for major browsers across Windows, macOS, and Linux, as well as the system PATH.
13
+ """
14
+
15
+ CHROMIUM_EXECUTABLE_NAMES = [
16
+ "chrome",
17
+ "chrome.exe",
18
+ "msedge",
19
+ "msedge.exe",
20
+ "brave",
21
+ "brave.exe",
22
+ "brave-browser",
23
+ "chromium",
24
+ "chromium-browser",
25
+ "opera",
26
+ "opera.exe",
27
+ "vivaldi",
28
+ "vivaldi.exe",
29
+ ]
30
+
31
+ @classmethod
32
+ def find(cls) -> List[str]:
33
+ """
34
+ Find installed Chromium-based browsers.
35
+
36
+ Returns:
37
+ A list of file paths to detected browser executables. The list may be empty if no browsers are found.
38
+ """
39
+
40
+ paths = []
41
+ paths.extend(cls._from_path())
42
+
43
+ if sys.platform.startswith("win"):
44
+ paths.extend(cls._windows_paths())
45
+
46
+ elif sys.platform == "darwin":
47
+ paths.extend(cls._macos_paths())
48
+
49
+ else:
50
+ paths.extend(cls._linux_paths())
51
+
52
+ return cls._unique_existing(paths)
53
+
54
+ @classmethod
55
+ def _from_path(cls) -> List[str]:
56
+ """
57
+ Find browser executables in the system PATH.
58
+
59
+ Returns:
60
+ A list of file paths to browser executables found in the PATH.
61
+ """
62
+
63
+ paths = []
64
+
65
+ for name in cls.CHROMIUM_EXECUTABLE_NAMES:
66
+ found = which(name)
67
+
68
+ if found:
69
+ paths.append(found)
70
+
71
+ return paths
72
+
73
+ @staticmethod
74
+ def _windows_paths() -> List[Path]:
75
+ """
76
+ Common installation paths for Chromium-based browsers on Windows.
77
+
78
+ Returns:
79
+ A list of file paths to browser executables based on common Windows installation directories.
80
+ """
81
+
82
+ local = os.environ.get("LOCALAPPDATA", "")
83
+ pf = os.environ.get("PROGRAMFILES", "")
84
+ pf86 = os.environ.get("PROGRAMFILES(X86)", "")
85
+
86
+ return [
87
+ Path(local) / "Google" / "Chrome" / "Application" / "chrome.exe",
88
+ Path(pf) / "Google" / "Chrome" / "Application" / "chrome.exe",
89
+ Path(pf86) / "Google" / "Chrome" / "Application" / "chrome.exe",
90
+
91
+ Path(local) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
92
+ Path(pf) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
93
+ Path(pf86) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
94
+
95
+ Path(local) / "BraveSoftware" / "Brave-Browser" / "Application" / "brave.exe",
96
+ Path(pf) / "BraveSoftware" / "Brave-Browser" / "Application" / "brave.exe",
97
+ Path(pf86) / "BraveSoftware" / "Brave-Browser" / "Application" / "brave.exe",
98
+
99
+ Path(local) / "Programs" / "Opera" / "opera.exe",
100
+ Path(pf) / "Opera" / "opera.exe",
101
+
102
+ Path(local) / "Vivaldi" / "Application" / "vivaldi.exe",
103
+ Path(pf) / "Vivaldi" / "Application" / "vivaldi.exe",
104
+ ]
105
+
106
+ @staticmethod
107
+ def _macos_paths() -> List[str]:
108
+ """
109
+ Common installation paths for Chromium-based browsers on macOS.
110
+
111
+ Returns:
112
+ A list of file paths to browser executables based on common macOS installation directories.
113
+ """
114
+
115
+ return [
116
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
117
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
118
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
119
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
120
+ "/Applications/Opera.app/Contents/MacOS/Opera",
121
+ "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi",
122
+ ]
123
+
124
+ @staticmethod
125
+ def _linux_paths() -> List[str]:
126
+ """
127
+ Common installation paths for Chromium-based browsers on Linux.
128
+
129
+ Returns:
130
+ A list of file paths to browser executables based on common Linux installation directories.
131
+ """
132
+
133
+ return [
134
+ "/usr/bin/google-chrome",
135
+ "/usr/bin/google-chrome-stable",
136
+ "/usr/bin/chromium",
137
+ "/usr/bin/chromium-browser",
138
+ "/usr/bin/microsoft-edge",
139
+ "/usr/bin/brave-browser",
140
+ "/usr/bin/opera",
141
+ "/usr/bin/vivaldi",
142
+ "/snap/bin/chromium",
143
+ "/snap/bin/brave",
144
+ ]
145
+
146
+ @staticmethod
147
+ def _unique_existing(paths: List[Any]) -> List[str]:
148
+ """
149
+ Filter the given list of paths to include only unique entries that exist on the filesystem.
150
+
151
+ Returns:
152
+ A list of unique file paths that exist on the filesystem.
153
+ """
154
+
155
+ seen = set()
156
+ result = []
157
+
158
+ for path in paths:
159
+ if not path:
160
+ continue
161
+
162
+ path = str(Path(path))
163
+ normalized = path.lower()
164
+
165
+ if normalized in seen:
166
+ continue
167
+
168
+ if Path(path).exists():
169
+ seen.add(normalized)
170
+ result.append(path)
171
+
172
+ return result
@@ -0,0 +1,72 @@
1
+
2
+ import subprocess
3
+ import tempfile
4
+ import shutil
5
+
6
+ from typing import Optional
7
+ from .browser_finder import BrowserFinder
8
+
9
+ class BrowserProcess:
10
+ """
11
+ Manages the lifecycle of a browser process launched for authentication.
12
+ """
13
+
14
+ def __init__(self, url: str, port: int) -> None:
15
+ self.url = url
16
+ self.port = port
17
+ self.process: Optional[subprocess.Popen] = None
18
+ self.browser_path: Optional[str] = None
19
+ self.user_data_dir: Optional[str] = None
20
+
21
+ def start(self) -> "BrowserProcess":
22
+ """
23
+ Start the browser process with the appropriate flags for remote debugging.
24
+ """
25
+
26
+ browsers = BrowserFinder.find()
27
+
28
+ if not browsers:
29
+ raise Exception(
30
+ "No Chromium-based browser was found. Please install Chrome, Edge, Brave, Chromium, Opera, or Vivaldi."
31
+ )
32
+
33
+ self.browser_path = browsers[0]
34
+ self.user_data_dir = tempfile.mkdtemp(prefix="deepwrap-auth-profile-")
35
+
36
+ args = [
37
+ self.browser_path,
38
+ f"--remote-debugging-port={self.port}",
39
+ "--remote-debugging-address=127.0.0.1",
40
+ "--remote-allow-origins=*",
41
+ f"--user-data-dir={self.user_data_dir}",
42
+ "--no-first-run",
43
+ "--no-default-browser-check",
44
+ "--disable-popup-blocking",
45
+ self.url,
46
+ ]
47
+
48
+ self.process = subprocess.Popen(
49
+ args,
50
+ stdout=subprocess.DEVNULL,
51
+ stderr=subprocess.DEVNULL,
52
+ )
53
+
54
+ return self
55
+
56
+ def terminate(self) -> None:
57
+ """
58
+ Terminate the browser process and clean up the user data directory.
59
+ """
60
+
61
+ if self.process:
62
+ try:
63
+ self.process.terminate()
64
+
65
+ except Exception:
66
+ pass
67
+
68
+ if self.user_data_dir:
69
+ try:
70
+ shutil.rmtree(self.user_data_dir, ignore_errors=True)
71
+ except Exception:
72
+ pass
@@ -0,0 +1,82 @@
1
+ import json
2
+ import websocket
3
+
4
+ from typing import Dict, Any, Optional
5
+
6
+ class CDPClient:
7
+ """
8
+ Simple wrapper around a DevTools Protocol WebSocket connection.
9
+ - Provides methods to send commands and receive responses from the DevTools interface.
10
+ - Manages message IDs and connection lifecycle.
11
+ - Includes convenience methods to enable the Network and Page domains and to close the browser.
12
+ """
13
+
14
+ def __init__(self, websocket_url: str, timeout: int = 1) -> None:
15
+ self.websocket_url = websocket_url
16
+ self.timeout = timeout
17
+ self.message_id = 0
18
+ self.ws = websocket.create_connection(
19
+ websocket_url,
20
+ timeout=timeout,
21
+ suppress_origin=True,
22
+ )
23
+
24
+ def send(self, method: str, params: Optional[Dict[str, Any]] = None) -> int:
25
+ """
26
+ Send a command to the DevTools interface.
27
+
28
+ Args:
29
+ method (str): The DevTools method to invoke (e.g., "Network.enable").
30
+ params (Optional[Dict[str, Any]]): An optional dictionary of parameters to include with the command.
31
+
32
+ Returns:
33
+ int: The message ID assigned to this command, which can be used to correlate responses.
34
+ """
35
+
36
+ self.message_id += 1
37
+
38
+ payload = {
39
+ "id": self.message_id,
40
+ "method": method,
41
+ }
42
+
43
+ if params is not None:
44
+ payload["params"] = params
45
+
46
+ self.ws.send(json.dumps(payload))
47
+ return self.message_id
48
+
49
+ def recv(self) -> Dict[str, Any]:
50
+ """
51
+ Receive a message from the DevTools interface and parse it as JSON.
52
+ """
53
+
54
+ return json.loads(self.ws.recv())
55
+
56
+ def enable_network(self) -> None:
57
+ """
58
+ Convenience method to enable the Network domain.
59
+ """
60
+
61
+ self.send("Network.enable")
62
+ self.send("Page.enable")
63
+
64
+ def close_browser(self) -> None:
65
+ """
66
+ Send the command to close the browser window.
67
+ """
68
+
69
+ try:
70
+ self.send("Browser.close")
71
+ except Exception:
72
+ pass
73
+
74
+ def close(self) -> None:
75
+ """
76
+ Close the WebSocket connection.
77
+ """
78
+
79
+ try:
80
+ self.ws.close()
81
+ except Exception:
82
+ pass