Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-altcha for openSUSE:Factory checked in at 2026-08-05 17:49:40 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-altcha (Old) and /work/SRC/openSUSE:Factory/.python-altcha.new.16738 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-altcha" Wed Aug 5 17:49:40 2026 rev:8 rq:1369601 version:2.1.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-altcha/python-altcha.changes 2026-06-30 15:12:58.559112544 +0200 +++ /work/SRC/openSUSE:Factory/.python-altcha.new.16738/python-altcha.changes 2026-08-05 17:50:36.210487048 +0200 @@ -1,0 +2,7 @@ +Tue Aug 4 22:10:18 UTC 2026 - Dirk Müller <[email protected]> + +- update to 2.1.0: + * feat: verify_server function for remote server verification + * fix: enforce key_prefix in PoW v2 fallback verification + +------------------------------------------------------------------- Old: ---- altcha-2.0.2.tar.gz New: ---- altcha-2.1.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-altcha.spec ++++++ --- /var/tmp/diff_new_pack.oUcBBU/_old 2026-08-05 17:50:36.766506499 +0200 +++ /var/tmp/diff_new_pack.oUcBBU/_new 2026-08-05 17:50:36.766506499 +0200 @@ -17,7 +17,7 @@ Name: python-altcha -Version: 2.0.2 +Version: 2.1.0 Release: 0 Summary: A library for creating and verifying challenges for ALTCHA License: MIT @@ -32,8 +32,9 @@ %python_subpackages %description -The ALTCHA Python Library is a lightweight, zero-dependency library designed for creating and verifying -[ALTCHA](https://altcha.org) challenges, specifically tailored for Python applications. +The ALTCHA Python Library is a lightweight, zero-dependency library designed +for creating and verifying [ALTCHA](https://altcha.org) challenges, +specifically tailored for Python applications. %prep %autosetup -p1 -n altcha-%{version} ++++++ altcha-2.0.2.tar.gz -> altcha-2.1.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/altcha-2.0.2/PKG-INFO new/altcha-2.1.0/PKG-INFO --- old/altcha-2.0.2/PKG-INFO 2026-06-20 12:15:40.391074000 +0200 +++ new/altcha-2.1.0/PKG-INFO 2026-07-27 09:45:51.430548200 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: altcha -Version: 2.0.2 +Version: 2.1.0 Summary: A library for creating and verifying challenges for ALTCHA. Home-page: https://github.com/altcha-org/altcha-lib-py Author: Daniel Regeci @@ -95,7 +95,7 @@ # Server: verify result = verify_solution(payload_b64, HMAC_SECRET) -print(result.verified) # True +print(result.verified) # True ``` ### Deterministic mode @@ -136,7 +136,8 @@ challenge = create_challenge( algorithm="PBKDF2/SHA-256", cost=5_000, - expires_at=datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=10), + expires_at=datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=10), hmac_secret=HMAC_SECRET, ) ``` @@ -146,8 +147,8 @@ Pass your own `derive_key` function to use a custom or third-party KDF: ```python -def my_derive_key(parameters, salt: bytes, password: bytes) -> bytes: - ... +def my_derive_key(parameters, salt: bytes, password: bytes) -> bytes: ... + challenge = create_challenge( algorithm="MY-ALGO", @@ -256,6 +257,48 @@ Verifies an ALTCHA server signature. +#### `verify_server(payload, url, secret, *, headers, timeout, retries, retry_delay, retry_backoff, http_post) → VerifyServerResult` + +Verifies a payload remotely via the ALTCHA Sentinel `/v1/verify/signature` API, instead of +checking the HMAC signature locally. Avoids managing the HMAC secret on your server, at the +cost of a network round-trip. + +```python +from altcha import verify_server + +result = verify_server( + payload, # the payload received from POST /v1/verify + url="https://sentinel.example.com/v1/verify/signature", + secret=API_KEY_SECRET, # optional, checked against the payload's API key + timeout=10, + retries=2, +) + +if result.verified: + ... +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `payload` | `str` \| `ServerSignaturePayload` \| `dict` | — | The payload to verify. | +| `url` | `str` | — | Full URL of the Sentinel `/v1/verify/signature` endpoint. | +| `secret` | `str` | `None` | API key secret, checked against the payload's API key. | +| `headers` | `dict` | `None` | Additional headers to send with the request. | +| `timeout` | `float` | `10` | Per-attempt request timeout in seconds. | +| `retries` | `int` | `0` | Number of retry attempts after the first try. | +| `retry_delay` | `float` | `0.3` | Base delay in seconds between retries. | +| `retry_backoff` | `str` | `'exponential'` | `'fixed'` or `'exponential'` backoff. | +| `http_post` | callable | stdlib `urllib` | Transport override: `(url, body, headers, timeout) -> (status, body)`. | + +Returns `VerifyServerResult` with fields: + +| Field | Type | Description | +|---|---|---| +| `verified` | `bool` | `True` if Sentinel confirmed the payload is valid. | +| `reason` | `str \| None` | Failure reason, if any. | +| `api_key` | `str \| None` | The API key associated with the payload, if returned. | +| `verification_data` | `dict \| None` | Parsed verification data, if returned. | + --- ### V1 (legacy) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/altcha-2.0.2/README.md new/altcha-2.1.0/README.md --- old/altcha-2.0.2/README.md 2026-06-20 12:15:36.000000000 +0200 +++ new/altcha-2.1.0/README.md 2026-07-27 09:45:45.000000000 +0200 @@ -72,7 +72,7 @@ # Server: verify result = verify_solution(payload_b64, HMAC_SECRET) -print(result.verified) # True +print(result.verified) # True ``` ### Deterministic mode @@ -113,7 +113,8 @@ challenge = create_challenge( algorithm="PBKDF2/SHA-256", cost=5_000, - expires_at=datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=10), + expires_at=datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=10), hmac_secret=HMAC_SECRET, ) ``` @@ -123,8 +124,8 @@ Pass your own `derive_key` function to use a custom or third-party KDF: ```python -def my_derive_key(parameters, salt: bytes, password: bytes) -> bytes: - ... +def my_derive_key(parameters, salt: bytes, password: bytes) -> bytes: ... + challenge = create_challenge( algorithm="MY-ALGO", @@ -233,6 +234,48 @@ Verifies an ALTCHA server signature. +#### `verify_server(payload, url, secret, *, headers, timeout, retries, retry_delay, retry_backoff, http_post) → VerifyServerResult` + +Verifies a payload remotely via the ALTCHA Sentinel `/v1/verify/signature` API, instead of +checking the HMAC signature locally. Avoids managing the HMAC secret on your server, at the +cost of a network round-trip. + +```python +from altcha import verify_server + +result = verify_server( + payload, # the payload received from POST /v1/verify + url="https://sentinel.example.com/v1/verify/signature", + secret=API_KEY_SECRET, # optional, checked against the payload's API key + timeout=10, + retries=2, +) + +if result.verified: + ... +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `payload` | `str` \| `ServerSignaturePayload` \| `dict` | — | The payload to verify. | +| `url` | `str` | — | Full URL of the Sentinel `/v1/verify/signature` endpoint. | +| `secret` | `str` | `None` | API key secret, checked against the payload's API key. | +| `headers` | `dict` | `None` | Additional headers to send with the request. | +| `timeout` | `float` | `10` | Per-attempt request timeout in seconds. | +| `retries` | `int` | `0` | Number of retry attempts after the first try. | +| `retry_delay` | `float` | `0.3` | Base delay in seconds between retries. | +| `retry_backoff` | `str` | `'exponential'` | `'fixed'` or `'exponential'` backoff. | +| `http_post` | callable | stdlib `urllib` | Transport override: `(url, body, headers, timeout) -> (status, body)`. | + +Returns `VerifyServerResult` with fields: + +| Field | Type | Description | +|---|---|---| +| `verified` | `bool` | `True` if Sentinel confirmed the payload is valid. | +| `reason` | `str \| None` | Failure reason, if any. | +| `api_key` | `str \| None` | The API key associated with the payload, if returned. | +| `verification_data` | `dict \| None` | Parsed verification data, if returned. | + --- ### V1 (legacy) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/altcha-2.0.2/altcha/__init__.py new/altcha-2.1.0/altcha/__init__.py --- old/altcha-2.0.2/altcha/__init__.py 2026-06-20 12:15:36.000000000 +0200 +++ new/altcha-2.1.0/altcha/__init__.py 2026-07-27 09:45:45.000000000 +0200 @@ -11,6 +11,8 @@ from .v2 import VerifyServerSignatureResult as VerifyServerSignatureResult from .v2 import parse_verification_data as parse_verification_data from .v2 import verify_server_signature as verify_server_signature +from .v2 import VerifyServerResult as VerifyServerResult +from .v2 import verify_server as verify_server from .v2 import create_challenge as create_challenge from .v2 import solve_challenge as solve_challenge from .v2 import verify_solution as verify_solution diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/altcha-2.0.2/altcha/v2.py new/altcha-2.1.0/altcha/v2.py --- old/altcha-2.0.2/altcha/v2.py 2026-06-20 12:15:36.000000000 +0200 +++ new/altcha-2.1.0/altcha/v2.py 2026-07-27 09:45:45.000000000 +0200 @@ -8,7 +8,9 @@ import secrets import struct import time +import urllib.error import urllib.parse +import urllib.request from typing import Callable, Literal import datetime @@ -663,7 +665,8 @@ verified=valid, ) - # 4b. Slow path: re-derive the key from the counter and compare. + # 4b. Slow path: re-derive the key from the counter and compare, and + # require it to satisfy the signed key prefix. if derive_key is None: derive_key = _select_derive_key(params.algorithm) @@ -672,7 +675,9 @@ password = _make_password(nonce_bytes, solution.counter) recomputed = derive_key(params, salt_bytes, password) recomputed_hex = recomputed.hex() - invalid = not _constant_time_equal(recomputed_hex, solution.derived_key) + key_matches = _constant_time_equal(recomputed_hex, solution.derived_key) + prefix_matches = recomputed_hex.startswith(params.key_prefix) + invalid = not (key_matches and prefix_matches) return VerifySolutionResult( expired=False, @@ -714,6 +719,14 @@ self.verification_data = verification_data self.verified = verified + def to_dict(self) -> dict: + return { + "algorithm": self.algorithm, + "signature": self.signature, + "verificationData": self.verification_data, + "verified": self.verified, + } + @classmethod def from_dict(cls, d: dict) -> ServerSignaturePayload: return cls( @@ -859,3 +872,141 @@ verified=verified, verification_data=verification_data if verified else None, ) + + +# --------------------------------------------------------------------------- +# Remote (Sentinel API) verification +# --------------------------------------------------------------------------- + + +class VerifyServerResult: + """ + Result of a remote Sentinel verification via :func:`verify_server`. + + Attributes: + verified: ``True`` if Sentinel confirmed the payload is valid. + reason: Failure reason (e.g. an error code or exception message), if any. + api_key: The API key associated with the payload, if returned. + verification_data: Parsed verification data, if returned. + """ + + def __init__( + self, + verified: bool, + reason: str | None = None, + api_key: str | None = None, + verification_data: dict | None = None, + ): + self.verified = verified + self.reason = reason + self.api_key = api_key + self.verification_data = verification_data + + @classmethod + def from_dict(cls, d: dict) -> VerifyServerResult: + return cls( + verified=bool(d.get("verified", False)), + reason=d.get("reason"), + api_key=d.get("apiKey"), + verification_data=d.get("verificationData"), + ) + + +def _default_http_post( + url: str, data: bytes, headers: dict[str, str], timeout: float +) -> tuple[int, bytes]: + """Minimal stdlib HTTP POST, used as the default transport for :func:`verify_server`.""" + req = urllib.request.Request(url, data=data, method="POST", headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, resp.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + + +def _sleep_backoff(attempt: int, delay: float, backoff: str) -> None: + wait = delay if backoff == "fixed" else delay * (2**attempt) + time.sleep(wait) + + +def verify_server( + payload: str | ServerSignaturePayload | dict, + url: str, + secret: str | None = None, + *, + headers: dict[str, str] | None = None, + timeout: float = 10.0, + retries: int = 0, + retry_delay: float = 0.3, + retry_backoff: Literal["fixed", "exponential"] = "exponential", + http_post: Callable[[str, bytes, dict[str, str], float], tuple[int, bytes]] + | None = None, +) -> VerifyServerResult: + """ + Verify a payload remotely via the ALTCHA Sentinel ``/v1/verify/signature`` API. + + Instead of verifying the HMAC signature locally (see :func:`verify_server_signature`), + this POSTs the payload to Sentinel and trusts its response. Avoids managing the HMAC + secret on your server, at the cost of a network round-trip. + + Args: + payload: The payload to verify, as received from ``POST /v1/verify`` — a base64 + string, a :class:`ServerSignaturePayload`, or an already-decoded dict. + url: Full URL of the Sentinel ``/v1/verify/signature`` endpoint. + secret: API key secret. If given, Sentinel checks it against the payload's API key. + headers: Additional headers to send with the request. + timeout: Per-attempt request timeout in seconds. Defaults to ``10``. + retries: Number of retry attempts after the first try. Defaults to ``0``. + retry_delay: Base delay in seconds between retries. Defaults to ``0.3``. + retry_backoff: ``'fixed'`` or ``'exponential'`` backoff applied to *retry_delay*. + http_post: Transport override — ``(url, body, headers, timeout) -> (status, body)``. + Defaults to a stdlib ``urllib.request``-based implementation. Swap in another + HTTP client (e.g. ``requests``, ``httpx``) here if preferred. + + Returns: + A :class:`VerifyServerResult` describing the outcome. Failures (HTTP errors, network + errors, or a negative Sentinel verdict) are reported via ``verified=False`` with a + ``reason``, never raised as exceptions. + """ + if isinstance(payload, ServerSignaturePayload): + payload_value: str | dict = payload.to_dict() + else: + payload_value = payload + + body: dict = {"payload": payload_value} + if secret is not None: + body["secret"] = secret + data = json.dumps(body).encode() + + req_headers = {"Content-Type": "application/json", **(headers or {})} + post = http_post or _default_http_post + + attempts = retries + 1 + for attempt in range(attempts): + try: + status, resp_body = post(url, data, req_headers, timeout) + except Exception as e: + if attempt >= attempts - 1: + return VerifyServerResult( + verified=False, reason=str(e) or type(e).__name__ + ) + _sleep_backoff(attempt, retry_delay, retry_backoff) + continue + + if status == 400: + try: + err_data = json.loads(resp_body.decode()) + except Exception: + err_data = None + reason = (err_data or {}).get("error") or f"HTTP_{status}" + return VerifyServerResult(verified=False, reason=reason) + + if not (200 <= status < 300): + if attempt >= attempts - 1: + return VerifyServerResult(verified=False, reason=f"HTTP_{status}") + _sleep_backoff(attempt, retry_delay, retry_backoff) + continue + + return VerifyServerResult.from_dict(json.loads(resp_body.decode())) + + return VerifyServerResult(verified=False, reason="NETWORK_ERROR") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/altcha-2.0.2/altcha.egg-info/PKG-INFO new/altcha-2.1.0/altcha.egg-info/PKG-INFO --- old/altcha-2.0.2/altcha.egg-info/PKG-INFO 2026-06-20 12:15:40.000000000 +0200 +++ new/altcha-2.1.0/altcha.egg-info/PKG-INFO 2026-07-27 09:45:51.000000000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: altcha -Version: 2.0.2 +Version: 2.1.0 Summary: A library for creating and verifying challenges for ALTCHA. Home-page: https://github.com/altcha-org/altcha-lib-py Author: Daniel Regeci @@ -95,7 +95,7 @@ # Server: verify result = verify_solution(payload_b64, HMAC_SECRET) -print(result.verified) # True +print(result.verified) # True ``` ### Deterministic mode @@ -136,7 +136,8 @@ challenge = create_challenge( algorithm="PBKDF2/SHA-256", cost=5_000, - expires_at=datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=10), + expires_at=datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=10), hmac_secret=HMAC_SECRET, ) ``` @@ -146,8 +147,8 @@ Pass your own `derive_key` function to use a custom or third-party KDF: ```python -def my_derive_key(parameters, salt: bytes, password: bytes) -> bytes: - ... +def my_derive_key(parameters, salt: bytes, password: bytes) -> bytes: ... + challenge = create_challenge( algorithm="MY-ALGO", @@ -256,6 +257,48 @@ Verifies an ALTCHA server signature. +#### `verify_server(payload, url, secret, *, headers, timeout, retries, retry_delay, retry_backoff, http_post) → VerifyServerResult` + +Verifies a payload remotely via the ALTCHA Sentinel `/v1/verify/signature` API, instead of +checking the HMAC signature locally. Avoids managing the HMAC secret on your server, at the +cost of a network round-trip. + +```python +from altcha import verify_server + +result = verify_server( + payload, # the payload received from POST /v1/verify + url="https://sentinel.example.com/v1/verify/signature", + secret=API_KEY_SECRET, # optional, checked against the payload's API key + timeout=10, + retries=2, +) + +if result.verified: + ... +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `payload` | `str` \| `ServerSignaturePayload` \| `dict` | — | The payload to verify. | +| `url` | `str` | — | Full URL of the Sentinel `/v1/verify/signature` endpoint. | +| `secret` | `str` | `None` | API key secret, checked against the payload's API key. | +| `headers` | `dict` | `None` | Additional headers to send with the request. | +| `timeout` | `float` | `10` | Per-attempt request timeout in seconds. | +| `retries` | `int` | `0` | Number of retry attempts after the first try. | +| `retry_delay` | `float` | `0.3` | Base delay in seconds between retries. | +| `retry_backoff` | `str` | `'exponential'` | `'fixed'` or `'exponential'` backoff. | +| `http_post` | callable | stdlib `urllib` | Transport override: `(url, body, headers, timeout) -> (status, body)`. | + +Returns `VerifyServerResult` with fields: + +| Field | Type | Description | +|---|---|---| +| `verified` | `bool` | `True` if Sentinel confirmed the payload is valid. | +| `reason` | `str \| None` | Failure reason, if any. | +| `api_key` | `str \| None` | The API key associated with the payload, if returned. | +| `verification_data` | `dict \| None` | Parsed verification data, if returned. | + --- ### V1 (legacy) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/altcha-2.0.2/setup.py new/altcha-2.1.0/setup.py --- old/altcha-2.0.2/setup.py 2026-06-20 12:15:36.000000000 +0200 +++ new/altcha-2.1.0/setup.py 2026-07-27 09:45:45.000000000 +0200 @@ -2,7 +2,7 @@ setup( name="altcha", - version="2.0.2", + version="2.1.0", description="A library for creating and verifying challenges for ALTCHA.", long_description=open("README.md").read(), long_description_content_type="text/markdown", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/altcha-2.0.2/tests/test_altcha_v2.py new/altcha-2.1.0/tests/test_altcha_v2.py --- old/altcha-2.0.2/tests/test_altcha_v2.py 2026-06-20 12:15:36.000000000 +0200 +++ new/altcha-2.1.0/tests/test_altcha_v2.py 2026-07-27 09:45:45.000000000 +0200 @@ -1,8 +1,12 @@ import datetime +import json import struct import unittest +import unittest.mock from altcha.v2 import ( + DEFAULT_HMAC_ALGORITHM, + Challenge, ChallengeParameters, Payload, ServerSignaturePayload, @@ -10,12 +14,14 @@ _canonical_json, _hmac_v2, _make_password, + _sign_challenge_v2, create_challenge, derive_key_pbkdf2, derive_key_scrypt, derive_key_sha, parse_verification_data, solve_challenge, + verify_server, verify_server_signature, verify_solution, ) @@ -307,6 +313,42 @@ self.assertFalse(result.verified) self.assertTrue(result.invalid_solution) + def test_slow_path_enforces_key_prefix(self): + # Regression test: the fallback (no key signature) verification path must + # reject a solution whose derived key is genuinely correct for its counter + # but does not satisfy the challenge's key_prefix. Previously only + # derived_key == KDF(counter) was checked, letting a client submit any + # counter after a single KDF execution and skip the prefix search entirely. + ch = create_challenge("SHA-256", cost=10, hmac_secret=HMAC_KEY) + + # Learn the honest KDF output for counter 0 with exactly one hash + # computation: solve a probe copy of the challenge whose key_prefix is "" + # (matches immediately, no search). + probe_params = ChallengeParameters( + **{**ch.parameters.__dict__, "key_prefix": ""} + ) + probe_ch = Challenge(parameters=probe_params, signature=None) + honest = solve_challenge(probe_ch) + assert honest is not None + self.assertEqual(honest.counter, 0) + + # Pick a key_prefix the honest key is guaranteed not to satisfy: a byte + # can't be both 0x00 and 0xff. + mismatched_prefix = "ff" if honest.derived_key.startswith("00") else "00" + ch.parameters.key_prefix = mismatched_prefix + signed = _sign_challenge_v2( + DEFAULT_HMAC_ALGORITHM, ch.parameters, None, HMAC_KEY + ) + + # Submit the honestly-derived key/counter pair (one KDF execution, no + # prefix search) against the challenge whose signed key_prefix it does + # not satisfy. + bad_sol = Solution(counter=honest.counter, derived_key=honest.derived_key) + payload = Payload(signed, bad_sol).to_base64() + result = verify_solution(payload, HMAC_KEY) + self.assertFalse(result.verified) + self.assertTrue(result.invalid_solution) + def test_payload_object(self): ch = create_challenge("SHA-256", cost=1, counter=2, hmac_secret=HMAC_KEY) sol = solve_challenge(ch) @@ -464,5 +506,148 @@ self.assertTrue(result.invalid_signature) +class TestVerifyServer(unittest.TestCase): + URL = "https://sentinel.example.com/v1/verify/signature" + + def setUp(self): + self._sleep_calls: list[float] = [] + self._sleep_patcher = unittest.mock.patch( + "altcha.v2.time.sleep", side_effect=self._sleep_calls.append + ) + self._sleep_patcher.start() + self.addCleanup(self._sleep_patcher.stop) + + def test_success(self): + result_data = { + "apiKey": "key_1", + "verificationData": {"verified": True}, + "verified": True, + } + calls = [] + + def post(url, data, headers, timeout): + calls.append((url, data, headers, timeout)) + return 200, json.dumps(result_data).encode() + + result = verify_server("payload", self.URL, http_post=post) + self.assertTrue(result.verified) + self.assertEqual(result.api_key, "key_1") + self.assertEqual(result.verification_data, {"verified": True}) + self.assertIsNone(result.reason) + self.assertEqual(len(calls), 1) + + def test_secret_included_in_body(self): + calls = [] + + def post(url, data, headers, timeout): + calls.append(data) + return 200, json.dumps({"verified": True}).encode() + + verify_server("payload", self.URL, secret="sec_123", http_post=post) + self.assertEqual( + json.loads(calls[0]), {"payload": "payload", "secret": "sec_123"} + ) + + def test_secret_omitted_when_not_given(self): + calls = [] + + def post(url, data, headers, timeout): + calls.append(data) + return 200, json.dumps({"verified": True}).encode() + + verify_server("payload", self.URL, http_post=post) + self.assertEqual(json.loads(calls[0]), {"payload": "payload"}) + + def test_400_is_terminal_not_retried(self): + calls = [] + + def post(url, data, headers, timeout): + calls.append(1) + return 400, json.dumps({"error": "INVALID_PAYLOAD"}).encode() + + result = verify_server("payload", self.URL, retries=3, http_post=post) + self.assertFalse(result.verified) + self.assertEqual(result.reason, "INVALID_PAYLOAD") + self.assertEqual(len(calls), 1) + + def test_network_error_retried_then_fails(self): + calls = [] + + def post(url, data, headers, timeout): + calls.append(1) + raise OSError("fetch failed") + + result = verify_server("payload", self.URL, retries=2, http_post=post) + self.assertFalse(result.verified) + self.assertEqual(result.reason, "fetch failed") + self.assertEqual(len(calls), 3) + + def test_network_error_then_success(self): + calls = [] + + def post(url, data, headers, timeout): + calls.append(1) + if len(calls) < 2: + raise OSError("fetch failed") + return 200, json.dumps({"verified": True}).encode() + + result = verify_server("payload", self.URL, retries=2, http_post=post) + self.assertTrue(result.verified) + self.assertEqual(len(calls), 2) + + def test_server_signature_payload_serialized(self): + payload = ServerSignaturePayload( + algorithm="SHA-256", + signature="sig", + verification_data="verified=true", + verified=True, + ) + calls = [] + + def post(url, data, headers, timeout): + calls.append(data) + return 200, json.dumps({"verified": True}).encode() + + verify_server(payload, self.URL, http_post=post) + sent = json.loads(calls[0]) + self.assertEqual( + sent["payload"], + { + "algorithm": "SHA-256", + "signature": "sig", + "verificationData": "verified=true", + "verified": True, + }, + ) + + def test_fixed_backoff(self): + def post(url, data, headers, timeout): + raise OSError("fail") + + verify_server( + "payload", + self.URL, + retries=2, + retry_delay=0.5, + retry_backoff="fixed", + http_post=post, + ) + self.assertEqual(self._sleep_calls, [0.5, 0.5]) + + def test_exponential_backoff(self): + def post(url, data, headers, timeout): + raise OSError("fail") + + verify_server( + "payload", + self.URL, + retries=2, + retry_delay=0.5, + retry_backoff="exponential", + http_post=post, + ) + self.assertEqual(self._sleep_calls, [0.5, 1.0]) + + if __name__ == "__main__": unittest.main()
