Skip to content

Commit 52f95d0

Browse files
committed
Fix public verification and demo challenge trust
1 parent f15fe29 commit 52f95d0

8 files changed

Lines changed: 96 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 0.2.1 - 2026-03-07
4+
5+
- Made `obfuscated_text_lock` fail gracefully when verifying from a public-only challenge payload
6+
- Removed the demo server fallback that trusted client-supplied challenge objects during verification
7+
- Added regression tests for public challenge verification and demo challenge-ID enforcement
8+
39
## 0.2.0 - 2026-03-07
410

511
- Repositioned `agentproof` as an LLM-capability CAPTCHA library

demo/app.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -600,9 +600,6 @@ def load_challenge(payload: dict[str, Any]) -> Challenge | None:
600600
challenge_id = payload.get("challenge_id")
601601
if isinstance(challenge_id, str):
602602
return ISSUED_CHALLENGES.get(challenge_id)
603-
challenge_data = payload.get("challenge")
604-
if isinstance(challenge_data, dict):
605-
return Challenge(**challenge_data)
606603
return None
607604

608605

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "agentproof-ai"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
description = "LLM-capability CAPTCHA and obfuscated verification challenges for Python applications."
99
readme = "README.md"
1010
license = "MIT"

src/agentproof/challenges/obfuscated_text.py

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ def verify(self, challenge: Challenge, response: AgentResponse) -> VerificationR
9898
return VerificationResult.failure("challenge_type_mismatch")
9999
if utc_now() > parse_datetime(challenge.expires_at):
100100
return VerificationResult.failure("challenge_expired")
101+
expected_answer = challenge.private_data.get("expected_answer")
102+
if not isinstance(expected_answer, str):
103+
return VerificationResult.failure("missing_private_verification_data")
104+
template_id = challenge.private_data.get("template_id")
105+
if not isinstance(template_id, str):
106+
return VerificationResult.failure("missing_private_verification_data")
101107
answer = response.payload.get("answer")
102108
if not isinstance(answer, str) or not answer.strip():
103109
return VerificationResult.failure("missing_answer")
@@ -107,15 +113,14 @@ def verify(self, challenge: Challenge, response: AgentResponse) -> VerificationR
107113
normalized_answer = answer.strip().upper()
108114
if not _is_hyphen_answer(normalized_answer):
109115
return VerificationResult.failure("invalid_answer_format")
110-
expected_answer = self._expected_answer(challenge)
111116
if normalized_answer != expected_answer:
112117
return VerificationResult.failure(
113118
"answer_mismatch",
114119
expected_format="UPPERCASE-HYPHENATED",
115120
)
116121
return VerificationResult.success(
117122
answer=normalized_answer,
118-
template_id=self._template_id(challenge),
123+
template_id=template_id,
119124
difficulty=challenge.data.get("difficulty"),
120125
)
121126

@@ -141,21 +146,6 @@ def _template_builders() -> dict[str, TemplateBuilder]:
141146
"vowel_count": _build_vowel_count,
142147
}
143148

144-
@staticmethod
145-
def _expected_answer(challenge: Challenge) -> str:
146-
expected_answer = challenge.private_data.get("expected_answer")
147-
if not isinstance(expected_answer, str):
148-
raise ValueError("obfuscated_text_lock challenge is missing private expected_answer")
149-
return expected_answer
150-
151-
@staticmethod
152-
def _template_id(challenge: Challenge) -> str:
153-
template_id = challenge.private_data.get("template_id")
154-
if not isinstance(template_id, str):
155-
raise ValueError("obfuscated_text_lock challenge is missing private template_id")
156-
return template_id
157-
158-
159149
def _build_amber_sort(rng: random.Random) -> tuple[list[str], str]:
160150
amber_words = rng.sample(TOKEN_POOL, 3)
161151
noise_words = rng.sample([word for word in TOKEN_POOL if word not in amber_words], 2)

tests/test_api.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from datetime import timedelta
4+
from typing import Any, cast
45

56
import pytest
67

@@ -97,6 +98,20 @@ def test_obfuscated_text_rejects_wrong_answer() -> None:
9798
assert result.reason == "answer_mismatch"
9899

99100

101+
def test_obfuscated_text_public_roundtrip_fails_gracefully() -> None:
102+
challenge = generate_challenge(ChallengeSpec(challenge_type="obfuscated_text_lock"))
103+
public_payload = cast(dict[str, Any], challenge.to_dict())
104+
public_only = Challenge(**public_payload)
105+
response = AgentResponse(
106+
challenge_id=public_only.challenge_id,
107+
challenge_type=public_only.challenge_type,
108+
payload={"answer": "NOT-IMPORTANT"},
109+
)
110+
result = verify_response(public_only, response)
111+
assert not result.ok
112+
assert result.reason == "missing_private_verification_data"
113+
114+
100115
def test_semantic_math_rejects_wrong_word_count() -> None:
101116
spec = ChallengeSpec(
102117
challenge_type="semantic_math_lock",

tests/test_cli.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,36 @@ def test_cli_solve_obfuscated_returns_nonzero(tmp_path: Path) -> None:
134134
exit_code = main(["solve", str(challenge_file)])
135135
assert exit_code == 2
136136
assert "no built-in solver" in stderr.getvalue()
137+
138+
139+
def test_cli_verify_public_obfuscated_file_fails_cleanly(tmp_path: Path) -> None:
140+
internal_file = tmp_path / "challenge.internal.json"
141+
public_file = tmp_path / "challenge.public.json"
142+
response_file = tmp_path / "response.json"
143+
result_file = tmp_path / "result.json"
144+
145+
assert (
146+
main(
147+
[
148+
"generate",
149+
"obfuscated_text_lock",
150+
"--output",
151+
str(internal_file),
152+
"--public-output",
153+
str(public_file),
154+
]
155+
)
156+
== 0
157+
)
158+
159+
public_challenge = json.loads(public_file.read_text(encoding="utf-8"))
160+
response = {
161+
"challenge_id": public_challenge["challenge_id"],
162+
"challenge_type": public_challenge["challenge_type"],
163+
"payload": {"answer": "WRONG-ANSWER"},
164+
}
165+
response_file.write_text(json.dumps(response), encoding="utf-8")
166+
167+
assert main(["verify", str(public_file), str(response_file), "--output", str(result_file)]) == 1
168+
result = json.loads(result_file.read_text(encoding="utf-8"))
169+
assert result["reason"] == "missing_private_verification_data"

tests/test_demo_app.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,36 @@ def test_demo_obfuscated_manual_verify() -> None:
106106
server.shutdown()
107107
server.server_close()
108108
thread.join(timeout=5)
109+
110+
111+
def test_demo_verify_rejects_unknown_challenge_id() -> None:
112+
module = load_demo_app_module()
113+
server, thread = module.serve_in_background(port=0)
114+
host, port = cast(tuple[str, int], server.server_address)
115+
base_url = f"http://{host}:{port}"
116+
try:
117+
for _ in range(10):
118+
if thread.is_alive():
119+
break
120+
time.sleep(0.05)
121+
122+
try:
123+
request_json(
124+
f"{base_url}/api/verify",
125+
{
126+
"challenge_id": "not-issued",
127+
"response": {
128+
"challenge_id": "not-issued",
129+
"challenge_type": "proof_of_work",
130+
"payload": {"nonce": "0", "hash": "deadbeef"},
131+
},
132+
},
133+
)
134+
except Exception as exc: # noqa: BLE001
135+
assert "HTTP Error 404" in str(exc)
136+
else:
137+
raise AssertionError("expected verify request to fail")
138+
finally:
139+
server.shutdown()
140+
server.server_close()
141+
thread.join(timeout=5)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)