neptls 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.
neptls/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """NepTLS: an HTTP client and TLS research toolkit.
2
+
3
+ Developed by Diwas Khatri (@diwaskhatri07).
4
+ """
5
+
6
+ from .async_client import AsyncClient
7
+ from .client import Client, delete, get, head, options, patch, post, put, request
8
+ from .diagnostics import diagnose, inspect
9
+ from .exceptions import HTTPStatusError, NepTLSError, RequestError, TimeoutError
10
+ from .fingerprints import ClientFingerprint
11
+ from .models import RequestTiming, Response
12
+ from .profiles import BrowserProfile, PROFILES, get_profile
13
+ from .proxy import Proxy
14
+ from .tls import TLSConfig, TLSFingerprint
15
+ from . import crypto, fingerprints as fingerprint, profiles, pow, user_agents
16
+ from .user_agents import ua
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "AsyncClient",
22
+ "BrowserProfile",
23
+ "Client",
24
+ "ClientFingerprint",
25
+ "HTTPStatusError",
26
+ "NepTLSError",
27
+ "PROFILES",
28
+ "Proxy",
29
+ "RequestError",
30
+ "RequestTiming",
31
+ "Response",
32
+ "TLSConfig",
33
+ "TLSFingerprint",
34
+ "TimeoutError",
35
+ "crypto",
36
+ "delete",
37
+ "diagnose",
38
+ "fingerprint",
39
+ "get",
40
+ "get_profile",
41
+ "head",
42
+ "inspect",
43
+ "options",
44
+ "patch",
45
+ "post",
46
+ "pow",
47
+ "profiles",
48
+ "put",
49
+ "request",
50
+ "ua",
51
+ "user_agents",
52
+ ]
neptls/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
neptls/async_client.py ADDED
@@ -0,0 +1,52 @@
1
+ """Async API backed by the same tested synchronous transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from .client import Client
9
+ from .models import Response
10
+
11
+
12
+ class AsyncClient:
13
+ """An asyncio-friendly client with a persistent cookie/profile session."""
14
+
15
+ def __init__(self, **kwargs: Any) -> None:
16
+ self._client = Client(**kwargs)
17
+
18
+ async def request(self, method: str, url: str, **kwargs: Any) -> Response:
19
+ return await asyncio.to_thread(self._client.request, method, url, **kwargs)
20
+
21
+ async def get(self, url: str, **kwargs: Any) -> Response:
22
+ return await self.request("GET", url, **kwargs)
23
+
24
+ async def post(self, url: str, **kwargs: Any) -> Response:
25
+ return await self.request("POST", url, **kwargs)
26
+
27
+ async def put(self, url: str, **kwargs: Any) -> Response:
28
+ return await self.request("PUT", url, **kwargs)
29
+
30
+ async def patch(self, url: str, **kwargs: Any) -> Response:
31
+ return await self.request("PATCH", url, **kwargs)
32
+
33
+ async def delete(self, url: str, **kwargs: Any) -> Response:
34
+ return await self.request("DELETE", url, **kwargs)
35
+
36
+ async def head(self, url: str, **kwargs: Any) -> Response:
37
+ return await self.request("HEAD", url, **kwargs)
38
+
39
+ async def options(self, url: str, **kwargs: Any) -> Response:
40
+ return await self.request("OPTIONS", url, **kwargs)
41
+
42
+ def fingerprint(self):
43
+ return self._client.fingerprint()
44
+
45
+ async def close(self) -> None:
46
+ await asyncio.to_thread(self._client.close)
47
+
48
+ async def __aenter__(self) -> "AsyncClient":
49
+ return self
50
+
51
+ async def __aexit__(self, *_: object) -> None:
52
+ await self.close()
neptls/cli.py ADDED
@@ -0,0 +1,76 @@
1
+ """Command-line interface for NepTLS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from dataclasses import asdict
9
+
10
+ from . import __version__, get, profiles, ua
11
+ from .crypto import blake2, md5, sha256, sha512
12
+ from .diagnostics import inspect
13
+ from .pow import Challenge, solve
14
+
15
+
16
+ def _parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(prog="neptls", description="NepTLS networking research toolkit")
18
+ sub = parser.add_subparsers(dest="command")
19
+ for method in ("get", "post", "put", "patch", "delete", "head", "options"):
20
+ command = sub.add_parser(method, help=f"perform an HTTP {method.upper()} request")
21
+ command.add_argument("url")
22
+ command.add_argument("--json", dest="json_body", help="JSON request body")
23
+ command.add_argument("--header", action="append", default=[], metavar="NAME:VALUE")
24
+ for name in ("inspect", "diagnose"):
25
+ command = sub.add_parser(name, help="inspect DNS, TCP, TLS, and HTTP")
26
+ command.add_argument("url")
27
+ command.add_argument("--json", action="store_true", dest="as_json")
28
+ sub.add_parser("version")
29
+ profile_command = sub.add_parser("profile", help="list browser profiles")
30
+ profile_command.add_argument("name", nargs="?")
31
+ ua_command = sub.add_parser("ua", help="query the user-agent catalog")
32
+ ua_command.add_argument("kind", nargs="?", default="random", choices=("random", "chrome", "firefox", "mobile"))
33
+ hash_command = sub.add_parser("hash", help="hash text")
34
+ hash_command.add_argument("value")
35
+ hash_command.add_argument("--algorithm", choices=("sha256", "sha512", "blake2", "md5"), default="sha256")
36
+ pow_command = sub.add_parser("pow", help="solve a generic proof-of-work challenge")
37
+ pow_command.add_argument("payload")
38
+ pow_command.add_argument("--difficulty", type=int, default=4)
39
+ return parser
40
+
41
+
42
+ def main(argv: list[str] | None = None) -> int:
43
+ args = _parser().parse_args(argv)
44
+ if args.command == "version":
45
+ print(__version__)
46
+ return 0
47
+ if args.command in {"get", "post", "put", "patch", "delete", "head", "options"}:
48
+ headers = dict(item.split(":", 1) for item in args.header if ":" in item)
49
+ body = json.loads(args.json_body) if args.json_body else None
50
+ response = getattr(__import__("neptls"), args.command)(args.url, headers=headers, json=body)
51
+ print(response.text)
52
+ return 0 if response.ok else 1
53
+ if args.command in {"inspect", "diagnose"}:
54
+ print(json.dumps(inspect(args.url), indent=2, default=str))
55
+ return 0
56
+ if args.command == "profile":
57
+ value = profiles.PROFILES if not args.name else profiles.get_profile(args.name).to_dict()
58
+ print(json.dumps(value, indent=2, default=str))
59
+ return 0
60
+ if args.command == "ua":
61
+ print(getattr(ua, args.kind)())
62
+ return 0
63
+ if args.command == "hash":
64
+ function = {"sha256": sha256, "sha512": sha512, "blake2": blake2, "md5": md5}[args.algorithm]
65
+ print(function(args.value))
66
+ return 0
67
+ if args.command == "pow":
68
+ result = solve(Challenge(args.payload, difficulty=args.difficulty))
69
+ print(json.dumps(asdict(result), default=str))
70
+ return 0
71
+ _parser().print_help()
72
+ return 0
73
+
74
+
75
+ if __name__ == "__main__":
76
+ sys.exit(main())
neptls/client.py ADDED
@@ -0,0 +1,285 @@
1
+ """Synchronous requests-like HTTP client built on Python's standard library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json as json_module
7
+ import mimetypes
8
+ import random
9
+ import time
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from collections.abc import Callable, Mapping
14
+ from http.cookiejar import CookieJar
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .exceptions import RequestError, TimeoutError
19
+ from .models import RequestTiming, Response
20
+ from .profiles import BrowserProfile, get_profile
21
+ from .tls import TLSConfig, TLSFingerprint
22
+
23
+ AuthHook = Callable[[urllib.request.Request], None]
24
+
25
+
26
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
27
+ def redirect_request(self, *_: Any) -> None:
28
+ return None
29
+
30
+
31
+ def _multipart(fields: Mapping[str, Any], files: Mapping[str, Any]) -> tuple[bytes, str]:
32
+ boundary = f"----neptls-{random.randrange(10**12):012d}"
33
+ buffer = io.BytesIO()
34
+ for name, value in fields.items():
35
+ buffer.write(f"--{boundary}\r\n".encode())
36
+ buffer.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
37
+ buffer.write(str(value).encode())
38
+ buffer.write(b"\r\n")
39
+ for name, value in files.items():
40
+ filename, content, content_type = name, value, None
41
+ if isinstance(value, tuple):
42
+ filename, content = value[0], value[1]
43
+ content_type = value[2] if len(value) > 2 else None
44
+ if hasattr(content, "read"):
45
+ content = content.read()
46
+ elif isinstance(content, (str, Path)) and Path(content).exists():
47
+ path = Path(content)
48
+ filename, content = filename or path.name, path.read_bytes()
49
+ elif isinstance(content, str):
50
+ content = content.encode()
51
+ content_type = content_type or mimetypes.guess_type(str(filename))[0] or "application/octet-stream"
52
+ buffer.write(f"--{boundary}\r\n".encode())
53
+ buffer.write(f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode())
54
+ buffer.write(f"Content-Type: {content_type}\r\n\r\n".encode())
55
+ buffer.write(content)
56
+ buffer.write(b"\r\n")
57
+ buffer.write(f"--{boundary}--\r\n".encode())
58
+ return buffer.getvalue(), f"multipart/form-data; boundary={boundary}"
59
+
60
+
61
+ class Client:
62
+ """Reusable HTTP client with cookies, connection-aware opener, and retries."""
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ profile: str | BrowserProfile | None = None,
68
+ proxy: str | None = None,
69
+ headers: Mapping[str, str] | None = None,
70
+ cookies: Mapping[str, str] | None = None,
71
+ timeout: float = 30.0,
72
+ verify: bool = True,
73
+ follow_redirects: bool = True,
74
+ retries: int = 0,
75
+ backoff_factor: float = 0.2,
76
+ ) -> None:
77
+ self.profile = get_profile(profile)
78
+ self.timeout = timeout
79
+ self.follow_redirects = follow_redirects
80
+ self.retries = max(0, retries)
81
+ self.backoff_factor = max(0.0, backoff_factor)
82
+ self.cookies = CookieJar()
83
+ self._headers = dict(self.profile.headers if self.profile else {})
84
+ if self.profile:
85
+ self._headers.setdefault("User-Agent", self.profile.user_agent)
86
+ if headers:
87
+ self._headers.update(headers)
88
+ if cookies:
89
+ for name, value in cookies.items():
90
+ self.cookies.set_cookie(
91
+ __import__("http.cookiejar", fromlist=["Cookie"]).Cookie(
92
+ version=0,
93
+ name=name,
94
+ value=value,
95
+ port=None,
96
+ port_specified=False,
97
+ domain="",
98
+ domain_specified=False,
99
+ domain_initial_dot=False,
100
+ path="/",
101
+ path_specified=True,
102
+ secure=False,
103
+ expires=None,
104
+ discard=True,
105
+ comment=None,
106
+ comment_url=None,
107
+ rest={},
108
+ rfc2109=False,
109
+ )
110
+ )
111
+ if self.profile:
112
+ tls_config = self.profile.tls
113
+ else:
114
+ tls_config = TLSConfig(verify=verify)
115
+ if not verify:
116
+ tls_config = TLSConfig(**{**tls_config.to_dict(), "verify": False})
117
+ handlers: list[Any] = [
118
+ urllib.request.HTTPCookieProcessor(self.cookies),
119
+ urllib.request.HTTPSHandler(context=tls_config.ssl_context()),
120
+ ]
121
+ if proxy:
122
+ handlers.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
123
+ if not follow_redirects:
124
+ handlers.append(_NoRedirect())
125
+ self._opener = urllib.request.build_opener(*handlers)
126
+
127
+ def _url(self, url: str, params: Mapping[str, Any] | None) -> str:
128
+ if not params:
129
+ return url
130
+ parsed = urllib.parse.urlsplit(url)
131
+ query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
132
+ query.extend((key, str(value)) for key, value in params.items())
133
+ return urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query)))
134
+
135
+ def request(
136
+ self,
137
+ method: str,
138
+ url: str,
139
+ *,
140
+ params: Mapping[str, Any] | None = None,
141
+ headers: Mapping[str, str] | None = None,
142
+ data: bytes | str | None = None,
143
+ json: Any = None,
144
+ form: Mapping[str, Any] | None = None,
145
+ files: Mapping[str, Any] | None = None,
146
+ timeout: float | None = None,
147
+ stream: bool = False,
148
+ auth: AuthHook | None = None,
149
+ ) -> Response:
150
+ url = self._url(url, params)
151
+ request_headers = dict(self._headers)
152
+ if headers:
153
+ request_headers.update(headers)
154
+ body: bytes | None = None
155
+ if json is not None:
156
+ body = json_module.dumps(json).encode()
157
+ request_headers.setdefault("Content-Type", "application/json")
158
+ elif files:
159
+ body, content_type = _multipart(form or {}, files)
160
+ request_headers.setdefault("Content-Type", content_type)
161
+ elif form is not None:
162
+ body = urllib.parse.urlencode(form).encode()
163
+ request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
164
+ elif data is not None:
165
+ body = data.encode() if isinstance(data, str) else data
166
+ request = urllib.request.Request(url, data=body, headers=request_headers, method=method.upper())
167
+ if auth:
168
+ auth(request)
169
+ last_error: Exception | None = None
170
+ retry_statuses = {429, 500, 502, 503, 504}
171
+ for attempt in range(self.retries + 1):
172
+ started = time.perf_counter()
173
+ try:
174
+ raw = self._opener.open(request, timeout=timeout or self.timeout)
175
+ response = Response(
176
+ status_code=raw.status,
177
+ reason=raw.reason or "",
178
+ url=raw.geturl(),
179
+ headers=dict(raw.headers.items()),
180
+ raw=raw if stream else None,
181
+ content=b"" if stream else raw.read(),
182
+ request_method=method.upper(),
183
+ timing=RequestTiming(started, time.perf_counter(), first_byte=time.perf_counter()),
184
+ )
185
+ if not stream:
186
+ raw.close()
187
+ if response.status_code in retry_statuses and attempt < self.retries:
188
+ time.sleep(self.backoff_factor * (2**attempt))
189
+ continue
190
+ return response
191
+ except urllib.error.HTTPError as exc:
192
+ response = Response(
193
+ status_code=exc.code,
194
+ reason=exc.reason or "",
195
+ url=exc.geturl(),
196
+ headers=dict(exc.headers.items()) if exc.headers else {},
197
+ raw=exc if stream else None,
198
+ content=b"" if stream else exc.read(),
199
+ request_method=method.upper(),
200
+ timing=RequestTiming(started, time.perf_counter(), first_byte=time.perf_counter()),
201
+ )
202
+ if not stream:
203
+ exc.close()
204
+ if exc.code in retry_statuses and attempt < self.retries:
205
+ time.sleep(self.backoff_factor * (2**attempt))
206
+ continue
207
+ return response
208
+ except TimeoutError as exc:
209
+ last_error = exc
210
+ break
211
+ except (urllib.error.URLError, OSError) as exc:
212
+ last_error = exc
213
+ if attempt < self.retries:
214
+ time.sleep(self.backoff_factor * (2**attempt))
215
+ continue
216
+ break
217
+ if isinstance(last_error, TimeoutError):
218
+ raise TimeoutError(f"request timed out: {url}") from last_error
219
+ raise RequestError(f"request failed: {url}: {last_error}") from last_error
220
+
221
+ def get(self, url: str, **kwargs: Any) -> Response:
222
+ return self.request("GET", url, **kwargs)
223
+
224
+ def post(self, url: str, **kwargs: Any) -> Response:
225
+ return self.request("POST", url, **kwargs)
226
+
227
+ def put(self, url: str, **kwargs: Any) -> Response:
228
+ return self.request("PUT", url, **kwargs)
229
+
230
+ def patch(self, url: str, **kwargs: Any) -> Response:
231
+ return self.request("PATCH", url, **kwargs)
232
+
233
+ def delete(self, url: str, **kwargs: Any) -> Response:
234
+ return self.request("DELETE", url, **kwargs)
235
+
236
+ def head(self, url: str, **kwargs: Any) -> Response:
237
+ return self.request("HEAD", url, **kwargs)
238
+
239
+ def options(self, url: str, **kwargs: Any) -> Response:
240
+ return self.request("OPTIONS", url, **kwargs)
241
+
242
+ def fingerprint(self) -> TLSFingerprint:
243
+ return TLSFingerprint.from_config(self.profile.tls if self.profile else TLSConfig())
244
+
245
+ def close(self) -> None:
246
+ pass
247
+
248
+ def __enter__(self) -> "Client":
249
+ return self
250
+
251
+ def __exit__(self, *_: object) -> None:
252
+ self.close()
253
+
254
+
255
+ def request(method: str, url: str, **kwargs: Any) -> Response:
256
+ with Client() as client:
257
+ return client.request(method, url, **kwargs)
258
+
259
+
260
+ def get(url: str, **kwargs: Any) -> Response:
261
+ return request("GET", url, **kwargs)
262
+
263
+
264
+ def post(url: str, **kwargs: Any) -> Response:
265
+ return request("POST", url, **kwargs)
266
+
267
+
268
+ def put(url: str, **kwargs: Any) -> Response:
269
+ return request("PUT", url, **kwargs)
270
+
271
+
272
+ def patch(url: str, **kwargs: Any) -> Response:
273
+ return request("PATCH", url, **kwargs)
274
+
275
+
276
+ def delete(url: str, **kwargs: Any) -> Response:
277
+ return request("DELETE", url, **kwargs)
278
+
279
+
280
+ def head(url: str, **kwargs: Any) -> Response:
281
+ return request("HEAD", url, **kwargs)
282
+
283
+
284
+ def options(url: str, **kwargs: Any) -> Response:
285
+ return request("OPTIONS", url, **kwargs)
neptls/crypto.py ADDED
@@ -0,0 +1,62 @@
1
+ """Small, standard-library protocol encoding and hashing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+ from urllib.parse import quote, unquote
11
+
12
+
13
+ def _bytes(value: str | bytes | bytearray) -> bytes:
14
+ return value.encode() if isinstance(value, str) else bytes(value)
15
+
16
+
17
+ def sha256(value: str | bytes | bytearray) -> str:
18
+ return hashlib.sha256(_bytes(value)).hexdigest()
19
+
20
+
21
+ def sha512(value: str | bytes | bytearray) -> str:
22
+ return hashlib.sha512(_bytes(value)).hexdigest()
23
+
24
+
25
+ def blake2(value: str | bytes | bytearray, *, digest_size: int = 32) -> str:
26
+ return hashlib.blake2b(_bytes(value), digest_size=digest_size).hexdigest()
27
+
28
+
29
+ def md5(value: str | bytes | bytearray) -> str:
30
+ return hashlib.md5(_bytes(value), usedforsecurity=False).hexdigest()
31
+
32
+
33
+ def b64encode(value: str | bytes | bytearray) -> str:
34
+ return base64.b64encode(_bytes(value)).decode("ascii")
35
+
36
+
37
+ def b64decode(value: str | bytes) -> bytes:
38
+ return base64.b64decode(_bytes(value))
39
+
40
+
41
+ def hex_encode(value: str | bytes | bytearray) -> str:
42
+ return _bytes(value).hex()
43
+
44
+
45
+ def hex_decode(value: str) -> bytes:
46
+ return bytes.fromhex(value)
47
+
48
+
49
+ def url_encode(value: str) -> str:
50
+ return quote(value, safe="")
51
+
52
+
53
+ def url_decode(value: str) -> str:
54
+ return unquote(value)
55
+
56
+
57
+ def json_encode(value: Mapping[str, Any] | list[Any]) -> str:
58
+ return json.dumps(value, separators=(",", ":"), sort_keys=True)
59
+
60
+
61
+ def json_decode(value: str | bytes) -> Any:
62
+ return json.loads(value)
neptls/diagnostics.py ADDED
@@ -0,0 +1,59 @@
1
+ """Read-only DNS, TCP, TLS, and HTTP diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ import ssl
7
+ import time
8
+ from typing import Any
9
+ from urllib.parse import urlparse
10
+
11
+ from .client import Client
12
+
13
+
14
+ def inspect(url: str, *, timeout: float = 10.0) -> dict[str, Any]:
15
+ parsed = urlparse(url)
16
+ if not parsed.hostname:
17
+ raise ValueError("URL must include a hostname")
18
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
19
+ result: dict[str, Any] = {
20
+ "url": url,
21
+ "hostname": parsed.hostname,
22
+ "port": port,
23
+ "scheme": parsed.scheme,
24
+ "dns": {},
25
+ "tcp": {},
26
+ "tls": {},
27
+ "http": {},
28
+ }
29
+ dns_started = time.perf_counter()
30
+ addresses = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
31
+ result["dns"] = {
32
+ "duration": time.perf_counter() - dns_started,
33
+ "addresses": sorted({item[4][0] for item in addresses}),
34
+ }
35
+ tcp_started = time.perf_counter()
36
+ with socket.create_connection((parsed.hostname, port), timeout=timeout) as raw:
37
+ result["tcp"] = {"duration": time.perf_counter() - tcp_started, "connected": True}
38
+ if parsed.scheme == "https":
39
+ context = ssl.create_default_context()
40
+ tls_started = time.perf_counter()
41
+ with context.wrap_socket(raw, server_hostname=parsed.hostname) as secure:
42
+ result["tls"] = {
43
+ "duration": time.perf_counter() - tls_started,
44
+ "version": secure.version(),
45
+ "cipher": secure.cipher()[0] if secure.cipher() else None,
46
+ "alpn": secure.selected_alpn_protocol(),
47
+ "peer_certificate": secure.getpeercert(),
48
+ }
49
+ response = Client(timeout=timeout, retries=0).head(url)
50
+ result["http"] = {
51
+ "status_code": response.status_code,
52
+ "headers": response.headers,
53
+ "http_version": response.headers.get("x-http-version", "unknown"),
54
+ "duration": response.timing.total if response.timing else None,
55
+ }
56
+ return result
57
+
58
+
59
+ diagnose = inspect
neptls/exceptions.py ADDED
@@ -0,0 +1,29 @@
1
+ """NepTLS exception hierarchy."""
2
+
3
+
4
+ class NepTLSError(Exception):
5
+ """Base class for all NepTLS errors."""
6
+
7
+
8
+ class RequestError(NepTLSError):
9
+ """A network request could not be completed."""
10
+
11
+
12
+ class TimeoutError(RequestError):
13
+ """A request exceeded its configured timeout."""
14
+
15
+
16
+ class HTTPStatusError(RequestError):
17
+ """The server returned an unsuccessful HTTP status."""
18
+
19
+ def __init__(self, message: str, response: object) -> None:
20
+ super().__init__(message)
21
+ self.response = response
22
+
23
+
24
+ class InvalidProfileError(NepTLSError):
25
+ """A browser or TLS profile is invalid."""
26
+
27
+
28
+ class PowError(NepTLSError):
29
+ """A proof-of-work challenge is invalid or unsolved."""