valgix-humanpass 0.1.0__tar.gz

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,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .DS_Store
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.5
2
+ Name: valgix-humanpass
3
+ Version: 0.1.0
4
+ Summary: Official Python server SDK for Valgix HumanPass.
5
+ Project-URL: Documentation, https://docs.valgix.com/humanpass/
6
+ Project-URL: Repository, https://github.com/valgixcenter/humanpass-python
7
+ Project-URL: Issues, https://github.com/valgixcenter/humanpass-python/issues
8
+ Author: Valgix
9
+ Keywords: captcha,humanpass,security,valgix
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+
22
+ # HumanPass for Python
23
+
24
+ Official Python server SDK for [Valgix HumanPass](https://docs.valgix.com/humanpass/).
25
+
26
+ ```bash
27
+ pip install valgix-humanpass
28
+ ```
29
+
30
+ ```python
31
+ import os
32
+ from humanpass import HumanPass
33
+
34
+ humanpass = HumanPass(secret=os.environ["HUMANPASS_SECRET_KEY"])
35
+
36
+ result = humanpass.verify(
37
+ response=request.form["humanpass-response"],
38
+ expected_action="signup",
39
+ expected_hostname="example.com",
40
+ )
41
+
42
+ if not result.success:
43
+ # Reject the protected operation.
44
+ pass
45
+ ```
46
+
47
+ Keep the secret key on your server. Browser applications use the HumanPass widget and a public site key instead.
@@ -0,0 +1,26 @@
1
+ # HumanPass for Python
2
+
3
+ Official Python server SDK for [Valgix HumanPass](https://docs.valgix.com/humanpass/).
4
+
5
+ ```bash
6
+ pip install valgix-humanpass
7
+ ```
8
+
9
+ ```python
10
+ import os
11
+ from humanpass import HumanPass
12
+
13
+ humanpass = HumanPass(secret=os.environ["HUMANPASS_SECRET_KEY"])
14
+
15
+ result = humanpass.verify(
16
+ response=request.form["humanpass-response"],
17
+ expected_action="signup",
18
+ expected_hostname="example.com",
19
+ )
20
+
21
+ if not result.success:
22
+ # Reject the protected operation.
23
+ pass
24
+ ```
25
+
26
+ Keep the secret key on your server. Browser applications use the HumanPass widget and a public site key instead.
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "valgix-humanpass"
7
+ version = "0.1.0"
8
+ description = "Official Python server SDK for Valgix HumanPass."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Valgix" }]
12
+ keywords = ["humanpass", "captcha", "security", "valgix"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Typing :: Typed",
23
+ ]
24
+
25
+ [project.urls]
26
+ Documentation = "https://docs.valgix.com/humanpass/"
27
+ Repository = "https://github.com/valgixcenter/humanpass-python"
28
+ Issues = "https://github.com/valgixcenter/humanpass-python/issues"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/humanpass"]
32
+
33
+ [tool.pytest.ini_options]
34
+ pythonpath = ["src"]
35
+ testpaths = ["tests"]
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "build>=1.2",
40
+ "twine>=6.1",
41
+ ]
@@ -0,0 +1,21 @@
1
+ from .client import HumanPass
2
+ from .errors import (
3
+ HumanPassApiError,
4
+ HumanPassConfigurationError,
5
+ HumanPassError,
6
+ HumanPassTransportError,
7
+ )
8
+ from .models import SiteverifyFailure, SiteverifyResult, SiteverifySuccess
9
+
10
+ __all__ = [
11
+ "HumanPass",
12
+ "HumanPassApiError",
13
+ "HumanPassConfigurationError",
14
+ "HumanPassError",
15
+ "HumanPassTransportError",
16
+ "SiteverifyFailure",
17
+ "SiteverifyResult",
18
+ "SiteverifySuccess",
19
+ ]
20
+
21
+ __version__ = "0.1.0"
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import socket
6
+ from collections.abc import Callable
7
+ from typing import Any, Protocol, cast
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.parse import urlsplit, urlunsplit
10
+ from urllib.request import Request, urlopen
11
+
12
+ from .errors import (
13
+ HumanPassApiError,
14
+ HumanPassConfigurationError,
15
+ HumanPassTransportError,
16
+ )
17
+ from .models import (
18
+ SITEVERIFY_ERROR_CODES,
19
+ SiteverifyErrorCode,
20
+ SiteverifyFailure,
21
+ SiteverifyResult,
22
+ SiteverifySuccess,
23
+ )
24
+
25
+ DEFAULT_BASE_URL = "https://humanpass.valgix.com"
26
+ DEFAULT_TIMEOUT_SECONDS = 5.0
27
+ MAX_RESPONSE_BYTES = 64 * 1024
28
+ SECRET_PATTERN = re.compile(r"^hp_secret_(?:live|test)_[A-Za-z0-9_-]{16,}$")
29
+ RESPONSE_PATTERN = re.compile(r"^hp_response_[A-Za-z0-9._~-]{16,}$")
30
+ HOSTNAME_PATTERN = re.compile(
31
+ r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
32
+ r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$"
33
+ )
34
+
35
+
36
+ class HttpResponse(Protocol):
37
+ status: int
38
+ headers: Any
39
+
40
+ def read(self, amount: int = -1) -> bytes: ...
41
+
42
+ def __enter__(self) -> HttpResponse: ...
43
+
44
+ def __exit__(self, *args: object) -> None: ...
45
+
46
+
47
+ Transport = Callable[[Request, float], HttpResponse]
48
+
49
+
50
+ def _default_transport(request: Request, timeout: float) -> HttpResponse:
51
+ return cast(HttpResponse, urlopen(request, timeout=timeout))
52
+
53
+
54
+ def _normalize_base_url(value: str) -> str:
55
+ parsed = urlsplit(value)
56
+ if parsed.scheme not in {"https", "http"} or not parsed.hostname:
57
+ raise HumanPassConfigurationError("HumanPass base_url must be a valid HTTP URL.")
58
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
59
+ raise HumanPassConfigurationError("HumanPass base_url is not allowed.")
60
+ if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
61
+ raise HumanPassConfigurationError(
62
+ "HumanPass base_url must use HTTPS outside local development."
63
+ )
64
+ return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
65
+
66
+
67
+ def _normalize_hostname(value: str | None) -> str | None:
68
+ if value is None:
69
+ return None
70
+ normalized = value.strip().lower().rstrip(".")
71
+ if not HOSTNAME_PATTERN.fullmatch(normalized):
72
+ raise HumanPassConfigurationError(
73
+ "expected_hostname must be a valid hostname without a port."
74
+ )
75
+ return normalized
76
+
77
+
78
+ def _bounded_json(response: HttpResponse) -> dict[str, Any]:
79
+ raw = response.read(MAX_RESPONSE_BYTES + 1)
80
+ if len(raw) > MAX_RESPONSE_BYTES:
81
+ raise ValueError("oversized response")
82
+ value = json.loads(raw)
83
+ if not isinstance(value, dict):
84
+ raise ValueError("non-object response")
85
+ return value
86
+
87
+
88
+ def _parse_result(payload: dict[str, Any]) -> SiteverifyResult | None:
89
+ if payload.get("success") is False:
90
+ codes = payload.get("error_codes")
91
+ if not isinstance(codes, list) or not codes or not all(
92
+ isinstance(code, str) and code in SITEVERIFY_ERROR_CODES for code in codes
93
+ ):
94
+ return None
95
+ request_id = payload.get("request_id")
96
+ if request_id is not None and not isinstance(request_id, str):
97
+ return None
98
+ return SiteverifyFailure(
99
+ success=False,
100
+ error_codes=cast(tuple[SiteverifyErrorCode, ...], tuple(codes)),
101
+ request_id=request_id,
102
+ )
103
+
104
+ required = {
105
+ "challenge_ts": str,
106
+ "hostname": str,
107
+ "action": str,
108
+ "sitekey": str,
109
+ "verification_id": str,
110
+ }
111
+ if payload.get("success") is not True or any(
112
+ not isinstance(payload.get(key), expected_type)
113
+ for key, expected_type in required.items()
114
+ ):
115
+ return None
116
+ environment = payload.get("environment")
117
+ if environment not in {"live", "test"}:
118
+ return None
119
+ return SiteverifySuccess(
120
+ success=True,
121
+ challenge_ts=payload["challenge_ts"],
122
+ hostname=payload["hostname"],
123
+ action=payload["action"],
124
+ sitekey=payload["sitekey"],
125
+ environment=environment,
126
+ verification_id=payload["verification_id"],
127
+ )
128
+
129
+
130
+ class HumanPass:
131
+ def __init__(
132
+ self,
133
+ *,
134
+ secret: str,
135
+ base_url: str = DEFAULT_BASE_URL,
136
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
137
+ transport: Transport | None = None,
138
+ ) -> None:
139
+ if not SECRET_PATTERN.fullmatch(secret):
140
+ raise HumanPassConfigurationError("HumanPass secret has an invalid format.")
141
+ if timeout < 0.1:
142
+ raise HumanPassConfigurationError("HumanPass timeout must be at least 0.1 seconds.")
143
+ self.base_url = _normalize_base_url(base_url)
144
+ self.timeout = timeout
145
+ self.__secret = secret
146
+ self.__transport = transport or _default_transport
147
+
148
+ def verify(
149
+ self,
150
+ *,
151
+ response: str,
152
+ remote_ip: str | None = None,
153
+ idempotency_key: str | None = None,
154
+ expected_action: str | None = None,
155
+ expected_hostname: str | None = None,
156
+ ) -> SiteverifyResult:
157
+ if not RESPONSE_PATTERN.fullmatch(response):
158
+ return SiteverifyFailure(success=False, error_codes=("invalid_input_response",))
159
+ normalized_hostname = _normalize_hostname(expected_hostname)
160
+ body = {
161
+ "response": response,
162
+ **({"remoteip": remote_ip} if remote_ip else {}),
163
+ **({"idempotency_key": idempotency_key} if idempotency_key else {}),
164
+ }
165
+ request = Request(
166
+ f"{self.base_url}/v1/siteverify",
167
+ data=json.dumps(body, separators=(",", ":")).encode(),
168
+ headers={
169
+ "Authorization": f"Bearer {self.__secret}",
170
+ "Content-Type": "application/json",
171
+ "Accept": "application/json",
172
+ "User-Agent": "valgix-humanpass-python/0.1.0",
173
+ },
174
+ method="POST",
175
+ )
176
+
177
+ status = 0
178
+ request_id: str | None = None
179
+ try:
180
+ try:
181
+ result = self.__transport(request, self.timeout)
182
+ with result as http_response:
183
+ status = http_response.status
184
+ request_id = http_response.headers.get("X-Request-ID")
185
+ payload = _bounded_json(http_response)
186
+ except HTTPError as error:
187
+ status = error.code
188
+ request_id = error.headers.get("X-Request-ID")
189
+ payload = _bounded_json(cast(HttpResponse, error))
190
+ except (HTTPError, URLError, TimeoutError, socket.timeout, OSError):
191
+ raise HumanPassTransportError("HumanPass verification request failed.") from None
192
+ except (ValueError, json.JSONDecodeError):
193
+ raise HumanPassApiError(
194
+ "HumanPass returned an invalid response.",
195
+ status=status,
196
+ request_id=request_id,
197
+ ) from None
198
+
199
+ parsed = _parse_result(payload)
200
+ if isinstance(parsed, SiteverifyFailure):
201
+ return parsed
202
+ if status < 200 or status >= 300 or not isinstance(parsed, SiteverifySuccess):
203
+ code = payload.get("code") if isinstance(payload.get("code"), str) else None
204
+ raise HumanPassApiError(
205
+ "HumanPass returned an unexpected response.",
206
+ status=status,
207
+ request_id=request_id,
208
+ code=code,
209
+ )
210
+ if expected_action is not None and parsed.action != expected_action:
211
+ return SiteverifyFailure(
212
+ success=False,
213
+ error_codes=("action_mismatch",),
214
+ request_id=request_id,
215
+ )
216
+ if normalized_hostname is not None and parsed.hostname.lower().rstrip(".") != normalized_hostname:
217
+ return SiteverifyFailure(
218
+ success=False,
219
+ error_codes=("hostname_mismatch",),
220
+ request_id=request_id,
221
+ )
222
+ return parsed
@@ -0,0 +1,28 @@
1
+ class HumanPassError(Exception):
2
+ """Base class for sanitized HumanPass SDK failures."""
3
+
4
+ def __init__(self, message: str, *, request_id: str | None = None) -> None:
5
+ super().__init__(message)
6
+ self.request_id = request_id
7
+
8
+
9
+ class HumanPassConfigurationError(HumanPassError):
10
+ pass
11
+
12
+
13
+ class HumanPassTransportError(HumanPassError):
14
+ pass
15
+
16
+
17
+ class HumanPassApiError(HumanPassError):
18
+ def __init__(
19
+ self,
20
+ message: str,
21
+ *,
22
+ status: int,
23
+ request_id: str | None = None,
24
+ code: str | None = None,
25
+ ) -> None:
26
+ super().__init__(message, request_id=request_id)
27
+ self.status = status
28
+ self.code = code
@@ -0,0 +1,52 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal, TypeAlias
3
+
4
+ HumanPassEnvironment: TypeAlias = Literal["live", "test"]
5
+ SiteverifyErrorCode: TypeAlias = Literal[
6
+ "missing_input_response",
7
+ "invalid_input_response",
8
+ "timeout_or_duplicate",
9
+ "invalid_secret",
10
+ "site_disabled",
11
+ "action_mismatch",
12
+ "hostname_mismatch",
13
+ "invalid_idempotency_key",
14
+ "rate_limited",
15
+ "service_unavailable",
16
+ ]
17
+
18
+ SITEVERIFY_ERROR_CODES: frozenset[str] = frozenset(
19
+ {
20
+ "missing_input_response",
21
+ "invalid_input_response",
22
+ "timeout_or_duplicate",
23
+ "invalid_secret",
24
+ "site_disabled",
25
+ "action_mismatch",
26
+ "hostname_mismatch",
27
+ "invalid_idempotency_key",
28
+ "rate_limited",
29
+ "service_unavailable",
30
+ }
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class SiteverifySuccess:
36
+ success: Literal[True]
37
+ challenge_ts: str
38
+ hostname: str
39
+ action: str
40
+ sitekey: str
41
+ environment: HumanPassEnvironment
42
+ verification_id: str
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class SiteverifyFailure:
47
+ success: Literal[False]
48
+ error_codes: tuple[SiteverifyErrorCode, ...]
49
+ request_id: str | None = None
50
+
51
+
52
+ SiteverifyResult: TypeAlias = SiteverifySuccess | SiteverifyFailure
@@ -0,0 +1,80 @@
1
+ import io
2
+ import json
3
+ import unittest
4
+
5
+ from humanpass import HumanPass, HumanPassApiError, HumanPassConfigurationError
6
+
7
+ SECRET = "hp_secret_live_1234567890abcdefgh"
8
+ TOKEN = "hp_response_1234567890abcdefgh"
9
+ SUCCESS = {
10
+ "success": True,
11
+ "challenge_ts": "2026-08-22T12:00:00.000Z",
12
+ "hostname": "example.com",
13
+ "action": "signup",
14
+ "sitekey": "hp_site_live_1234567890abcdefgh",
15
+ "environment": "live",
16
+ "verification_id": "hpv_1234567890abcdefgh",
17
+ }
18
+
19
+
20
+ class Headers(dict[str, str]):
21
+ def get(self, key: str, default=None):
22
+ return super().get(key.lower(), default)
23
+
24
+
25
+ class Response:
26
+ def __init__(self, payload, *, status=200):
27
+ self.status = status
28
+ self.headers = Headers({"x-request-id": "11111111-1111-4111-8111-111111111111"})
29
+ self.stream = io.BytesIO(json.dumps(payload).encode())
30
+
31
+ def read(self, amount=-1):
32
+ return self.stream.read(amount)
33
+
34
+ def __enter__(self):
35
+ return self
36
+
37
+ def __exit__(self, *args):
38
+ return None
39
+
40
+
41
+ class HumanPassTests(unittest.TestCase):
42
+ def test_sends_secret_only_in_authorization_header(self):
43
+ captured = {}
44
+
45
+ def transport(request, timeout):
46
+ captured["request"] = request
47
+ captured["timeout"] = timeout
48
+ return Response(SUCCESS)
49
+
50
+ result = HumanPass(secret=SECRET, transport=transport).verify(
51
+ response=TOKEN,
52
+ remote_ip="203.0.113.10",
53
+ idempotency_key="signup-attempt-1",
54
+ )
55
+ request = captured["request"]
56
+ self.assertTrue(result.success)
57
+ self.assertEqual(request.get_header("Authorization"), f"Bearer {SECRET}")
58
+ self.assertNotIn(SECRET, request.data.decode())
59
+
60
+ def test_returns_semantic_action_mismatch(self):
61
+ client = HumanPass(secret=SECRET, transport=lambda *_: Response(SUCCESS))
62
+ result = client.verify(response=TOKEN, expected_action="checkout")
63
+ self.assertFalse(result.success)
64
+ self.assertEqual(result.error_codes, ("action_mismatch",))
65
+
66
+ def test_rejects_insecure_non_loopback_base_url(self):
67
+ with self.assertRaises(HumanPassConfigurationError):
68
+ HumanPass(secret=SECRET, base_url="http://example.com")
69
+
70
+ def test_rejects_oversized_api_response(self):
71
+ client = HumanPass(
72
+ secret=SECRET,
73
+ transport=lambda *_: Response({"padding": "x" * (70 * 1024)}),
74
+ )
75
+ with self.assertRaises(HumanPassApiError):
76
+ client.verify(response=TOKEN)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ unittest.main()