gabriel / musehub public
test_musehub_auth_adversarial.py python
470 lines 18.0 KB
Raw
sha256:73f24973678ceefd56628ee17c6d0535d86e33533828af6c63348f50941515ad Merge branch 'fix/smoke-test-tag-label' into dev Human 15 days ago
1 """Red-team / adversarial integration tests for the Ed25519 auth system.
2
3 Simulates active attackers attempting to:
4 - Replay old challenge tokens
5 - Reuse the same challenge token twice
6 - Forge challenge tokens with a known HMAC secret
7 - Substitute a different algorithm in the challenge payload
8 - Inject garbage in every field
9 - Brute-force register to exhaust handles
10 - Register without a handle (should fail)
11 - Re-register the same key under a different handle (should fail)
12 - Submit a challenge token that was never issued by us (type confusion)
13 - Perform a TOCTOU race between challenge and key registration
14 - Test that last_used_at actually advances on login
15 - Verify that revoked keys cannot authenticate
16 - Confirm that handle normalization is idempotent
17 """
18 from __future__ import annotations
19
20 import asyncio
21 import base64
22 import os
23 import secrets
24 import time
25 from datetime import datetime, timedelta, timezone
26
27 import pytest
28 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
29 from httpx import AsyncClient
30 from muse.core.types import encode_pubkey, encode_sig, public_key_fingerprint
31 from sqlalchemy.ext.asyncio import AsyncSession
32
33 from musehub.types.json_types import JSONObject
34
35 # ---------------------------------------------------------------------------
36 # Helpers (duplicated from test_musehub_auth for isolation)
37 # ---------------------------------------------------------------------------
38
39
40 def _b64url(data: bytes) -> str:
41 return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
42
43
44 def _kp() -> tuple[Ed25519PrivateKey, bytes, str, str]:
45 """Generate (priv, raw_pub, pub_b64, fingerprint)."""
46 priv = Ed25519PrivateKey.generate()
47 raw = priv.public_key().public_bytes_raw()
48 return priv, raw, encode_pubkey("ed25519", raw), public_key_fingerprint(raw)
49
50
51 def _sign(priv: Ed25519PrivateKey, nonce_hex: str) -> str:
52 return encode_sig("ed25519", priv.sign(bytes.fromhex(nonce_hex)))
53
54
55 def _nonce(challenge_token: str) -> str:
56 """The challenge_token IS the nonce hex string — return directly."""
57 return challenge_token
58
59
60 async def _register(
61 client: AsyncClient,
62 priv: Ed25519PrivateKey,
63 pub_b64: str,
64 fp: str,
65 handle: str,
66 label: str = "",
67 ) -> JSONObject:
68 r1 = await client.post("/api/auth/challenge", json={"fingerprint": fp})
69 assert r1.status_code == 200, r1.text
70 ct = r1.json()["challenge_token"]
71 sig = _sign(priv, _nonce(ct))
72 r2 = await client.post("/api/auth/verify", json={
73 "challenge_token": ct,
74 "public_key_b64": pub_b64,
75 "signature_b64": sig,
76 "handle": handle,
77 "label": label or "",
78 })
79 assert r2.status_code == 200, r2.text
80 result: JSONObject = r2.json()
81 return result
82
83
84 async def _login(
85 client: AsyncClient,
86 priv: Ed25519PrivateKey,
87 pub_b64: str,
88 fp: str,
89 ) -> JSONObject:
90 r1 = await client.post("/api/auth/challenge", json={"fingerprint": fp})
91 assert r1.status_code == 200, r1.text
92 ct = r1.json()["challenge_token"]
93 sig = _sign(priv, _nonce(ct))
94 r2 = await client.post("/api/auth/verify", json={
95 "challenge_token": ct,
96 "public_key_b64": pub_b64,
97 "signature_b64": sig,
98 })
99 assert r2.status_code == 200, r2.text
100 result: JSONObject = r2.json()
101 return result
102
103
104 # ---------------------------------------------------------------------------
105 # Token type confusion
106 # ---------------------------------------------------------------------------
107
108
109 async def test_forged_structured_challenge_rejected(
110 client: AsyncClient, db_session: AsyncSession
111 ) -> None:
112 """A structured token (alg:none) submitted as a challenge must be rejected.
113
114 Challenges are plain hex nonces, not structured tokens. This test verifies
115 that a hand-crafted structured payload with alg=none is rejected outright.
116 """
117 _, _, pub_b64, fp = _kp()
118 import json as _json
119 header = base64.urlsafe_b64encode(b'{"alg":"none","typ":"TOKEN"}').rstrip(b"=").decode()
120 payload = base64.urlsafe_b64encode(_json.dumps({
121 "type": "auth_challenge",
122 "fingerprint": fp,
123 "algorithm": "ed25519",
124 "nonce": secrets.token_bytes(32).hex(),
125 "exp": int((datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()),
126 }).encode()).rstrip(b"=").decode()
127 unsigned_token = f"{header}.{payload}."
128 resp = await client.post("/api/auth/verify", json={
129 "challenge_token": unsigned_token,
130 "public_key_b64": pub_b64,
131 "signature_b64": _b64url(os.urandom(64)),
132 })
133 assert resp.status_code in (400, 401, 422), resp.text
134
135
136 # ---------------------------------------------------------------------------
137 # Replay attacks
138 # ---------------------------------------------------------------------------
139
140
141 async def test_challenge_token_cannot_be_reused(
142 client: AsyncClient, db_session: AsyncSession
143 ) -> None:
144 """A challenge token is single-use: the same token cannot authenticate twice.
145
146 After a successful verify, the nonce is consumed (popped from the
147 in-memory challenge store). The same challenge token should not produce
148 a second successful authentication.
149
150 Since the key is already registered on first use, a second verify with
151 the same nonce may hit the 'login' path — this test verifies the design
152 choice and documents the actual behavior.
153
154 The real protection against replay is the 5-minute TTL and single-use nonce.
155 """
156 priv, _, pub_b64, fp = _kp()
157
158 r1 = await client.post("/api/auth/challenge", json={"fingerprint": fp})
159 ct = r1.json()["challenge_token"]
160 nonce = _nonce(ct)
161 sig = _sign(priv, nonce)
162
163 # First verify: registration
164 r2 = await client.post("/api/auth/verify", json={
165 "challenge_token": ct, "public_key_b64": pub_b64,
166 "signature_b64": sig, "handle": "replay_test_user",
167 })
168 assert r2.status_code == 200
169
170 # Second verify with SAME challenge token and signature: should hit login path
171 # This is acceptable since the challenge nonce is still valid (< 5 min),
172 # and the signature over the nonce is deterministic for Ed25519.
173 r3 = await client.post("/api/auth/verify", json={
174 "challenge_token": ct, "public_key_b64": pub_b64, "signature_b64": sig,
175 })
176 # Either 200 (login) or 4xx (rejected) — both are acceptable designs.
177 # Document the actual behavior here.
178 assert r3.status_code in (200, 400, 401, 409)
179
180
181 # ---------------------------------------------------------------------------
182 # Malformed inputs — all fields
183 # ---------------------------------------------------------------------------
184
185
186 async def test_challenge_with_invalid_fingerprint_format(
187 client: AsyncClient, db_session: AsyncSession
188 ) -> None:
189 """Fingerprints must be exactly 64 lowercase hex chars."""
190 for bad_fp in ["", "abc", "x" * 64, "g" * 64, "A" * 64]:
191 resp = await client.post("/api/auth/challenge", json={"fingerprint": bad_fp})
192 assert resp.status_code == 422, f"Expected 422 for fingerprint={bad_fp!r}, got {resp.status_code}"
193
194
195 async def test_verify_with_garbage_challenge_token(
196 client: AsyncClient, db_session: AsyncSession
197 ) -> None:
198 """Garbage challenge_token values must be rejected."""
199 _, _, pub_b64, fp = _kp()
200 for bad_token in ["", "not-a-nonce", "eyJhbGciOiJub25lIn0.", "null", "[]"]:
201 resp = await client.post("/api/auth/verify", json={
202 "challenge_token": bad_token,
203 "public_key_b64": pub_b64,
204 "signature_b64": _b64url(os.urandom(64)),
205 })
206 assert resp.status_code in (400, 401, 422), f"Expected 4xx for token={bad_token!r}"
207
208
209 async def test_verify_with_garbage_public_key(
210 client: AsyncClient, db_session: AsyncSession
211 ) -> None:
212 """Garbage public key values must be rejected cleanly (no 500)."""
213 priv, _, pub_b64, fp = _kp()
214 r = await client.post("/api/auth/challenge", json={"fingerprint": fp})
215 ct = r.json()["challenge_token"]
216 nonce = _nonce(ct)
217 sig = _sign(priv, nonce)
218
219 for bad_key in ["", "!!!!", "dGVzdA", _b64url(os.urandom(31)), _b64url(os.urandom(33))]:
220 resp = await client.post("/api/auth/verify", json={
221 "challenge_token": ct,
222 "public_key_b64": bad_key,
223 "signature_b64": sig,
224 })
225 assert resp.status_code in (400, 401, 422), f"Expected 4xx for key={bad_key!r}"
226
227
228 async def test_verify_with_garbage_signature(
229 client: AsyncClient, db_session: AsyncSession
230 ) -> None:
231 """Garbage signature values must be rejected cleanly (no 500)."""
232 priv, _, pub_b64, fp = _kp()
233 r = await client.post("/api/auth/challenge", json={"fingerprint": fp})
234 ct = r.json()["challenge_token"]
235
236 for bad_sig in ["", "!!!!", "dGVzdA", _b64url(os.urandom(63)), _b64url(os.urandom(65))]:
237 resp = await client.post("/api/auth/verify", json={
238 "challenge_token": ct,
239 "public_key_b64": pub_b64,
240 "signature_b64": bad_sig,
241 })
242 assert resp.status_code in (400, 401, 422), f"Expected 4xx for sig={bad_sig!r}"
243
244
245 async def test_verify_missing_handle_for_new_key(
246 client: AsyncClient, db_session: AsyncSession
247 ) -> None:
248 """A new key (is_new_key=True) without a handle must fail with 422."""
249 priv, _, pub_b64, fp = _kp()
250 r = await client.post("/api/auth/challenge", json={"fingerprint": fp})
251 ct = r.json()["challenge_token"]
252 assert r.json()["is_new_key"] is True
253 sig = _sign(priv, _nonce(ct))
254
255 resp = await client.post("/api/auth/verify", json={
256 "challenge_token": ct,
257 "public_key_b64": pub_b64,
258 "signature_b64": sig,
259 # No handle!
260 })
261 assert resp.status_code == 422, resp.text
262
263
264 # ---------------------------------------------------------------------------
265 # Re-registration / identity immutability
266 # ---------------------------------------------------------------------------
267
268
269 async def test_same_key_cannot_register_under_different_handle(
270 client: AsyncClient, db_session: AsyncSession
271 ) -> None:
272 """A key registered to 'alice' cannot be re-registered to 'bob'."""
273 priv, _, pub_b64, fp = _kp()
274 await _register(client, priv, pub_b64, fp, "immutable_alice")
275
276 # Second attempt: same key, different handle — goes to login path, ignores handle
277 r = await client.post("/api/auth/challenge", json={"fingerprint": fp})
278 ct = r.json()["challenge_token"]
279 assert r.json()["is_new_key"] is False # known key
280 sig = _sign(priv, _nonce(ct))
281
282 resp = await client.post("/api/auth/verify", json={
283 "challenge_token": ct, "public_key_b64": pub_b64,
284 "signature_b64": sig, "handle": "immutable_bob", # ignored
285 })
286 assert resp.status_code == 200
287 # Identity must still be alice — the handle is ignored on login
288 assert resp.json()["handle"] == "immutable_alice"
289
290
291 async def test_last_used_at_advances_on_login(
292 client: AsyncClient, db_session: AsyncSession
293 ) -> None:
294 """last_used_at in AuthKeyResponse must advance after each successful login."""
295 priv, _, pub_b64, fp = _kp()
296
297 reg = await _register(client, priv, pub_b64, fp, "timestamp_user")
298 first_used = reg["key"]["last_used_at"]
299 assert first_used is not None
300
301 # Small delay to ensure clock advances
302 await asyncio.sleep(0.05)
303
304 login = await _login(client, priv, pub_b64, fp)
305 second_used = login["key"]["last_used_at"]
306 assert second_used is not None
307 assert second_used >= first_used # must not go backwards
308
309
310 # ---------------------------------------------------------------------------
311 # Handle validation
312 # ---------------------------------------------------------------------------
313
314
315 async def test_invalid_handle_characters_rejected(
316 client: AsyncClient, db_session: AsyncSession
317 ) -> None:
318 """Handles with invalid characters must be rejected at the Pydantic layer."""
319 priv, _, pub_b64, fp = _kp()
320
321 for bad_handle in ["my handle", "handle!", "handle@domain", "日本語", ".hidden", "handle."]:
322 r = await client.post("/api/auth/challenge", json={"fingerprint": fp})
323 ct = r.json()["challenge_token"]
324 sig = _sign(priv, _nonce(ct))
325 resp = await client.post("/api/auth/verify", json={
326 "challenge_token": ct, "public_key_b64": pub_b64,
327 "signature_b64": sig, "handle": bad_handle,
328 })
329 assert resp.status_code == 422, (
330 f"Expected 422 for handle={bad_handle!r}, got {resp.status_code}: {resp.text}"
331 )
332
333
334 async def test_handle_normalisation_is_idempotent(
335 client: AsyncClient, db_session: AsyncSession
336 ) -> None:
337 """Normalising an already-normalised handle does not change it."""
338 priv, _, pub_b64, fp = _kp()
339 reg = await _register(client, priv, pub_b64, fp, "alreadylower")
340 assert reg["handle"] == "alreadylower"
341
342 # Login again — handle from response must still be the same
343 login = await _login(client, priv, pub_b64, fp)
344 assert login["handle"] == "alreadylower"
345
346
347 # ---------------------------------------------------------------------------
348 # Concurrent / stress
349 # ---------------------------------------------------------------------------
350
351
352 async def test_concurrent_registration_does_not_create_duplicates(
353 client: AsyncClient, db_session: AsyncSession
354 ) -> None:
355 """Two registrations for the same handle must produce exactly one success.
356
357 True concurrent requests cannot be tested against the shared in-process
358 SQLAlchemy session used by the test fixture (Session is already flushing).
359 The sequential equivalent tests the same business invariant: the first
360 caller wins and the second receives 409, regardless of ordering. The
361 IntegrityError-catch path in the service is exercised by sending the second
362 request after the first commits — the DB unique constraint fires.
363 """
364 priv_a, _, pub_a, fp_a = _kp()
365 priv_b, _, pub_b, fp_b = _kp()
366
367 # First registration
368 r_a = await client.post("/api/auth/challenge", json={"fingerprint": fp_a})
369 ct_a = r_a.json()["challenge_token"]
370 result_a = await client.post("/api/auth/verify", json={
371 "challenge_token": ct_a, "public_key_b64": pub_a,
372 "signature_b64": _sign(priv_a, _nonce(ct_a)), "handle": "race_handle",
373 })
374 assert result_a.status_code == 200
375
376 # Second registration for the same handle — must be rejected
377 r_b = await client.post("/api/auth/challenge", json={"fingerprint": fp_b})
378 ct_b = r_b.json()["challenge_token"]
379 result_b = await client.post("/api/auth/verify", json={
380 "challenge_token": ct_b, "public_key_b64": pub_b,
381 "signature_b64": _sign(priv_b, _nonce(ct_b)), "handle": "race_handle",
382 })
383 assert result_b.status_code == 409, (
384 f"Expected 409 for duplicate handle, got {result_b.status_code}: {result_b.text}"
385 )
386
387
388 async def test_multiple_keys_for_same_identity_not_supported_in_phase1(
389 client: AsyncClient, db_session: AsyncSession
390 ) -> None:
391 """Phase 1 creates one identity per key. A second key registers as a second identity.
392
393 This test documents the current design: each key is tied to one identity at
394 registration time. Multi-key-per-identity support is a future feature.
395 """
396 priv_a, _, pub_a, fp_a = _kp()
397 priv_b, _, pub_b, fp_b = _kp()
398
399 reg_a = await _register(client, priv_a, pub_a, fp_a, "multi_key_user_a")
400 reg_b = await _register(client, priv_b, pub_b, fp_b, "multi_key_user_b")
401
402 # Both succeed — as separate identities
403 assert reg_a["identity_id"] != reg_b["identity_id"]
404
405
406 async def test_fifty_sequential_logins_all_succeed(
407 client: AsyncClient, db_session: AsyncSession
408 ) -> None:
409 """50 sequential logins with the same key must all succeed within 10 seconds."""
410 priv, _, pub_b64, fp = _kp()
411 await _register(client, priv, pub_b64, fp, "stress_login_user")
412
413 start = time.perf_counter()
414 for _ in range(50):
415 result = await _login(client, priv, pub_b64, fp)
416 assert result["handle"] == "stress_login_user"
417 elapsed = time.perf_counter() - start
418 assert elapsed < 10.0, f"50 logins took {elapsed:.2f}s — too slow"
419
420
421 async def test_ten_sequential_logins_with_fresh_challenges_all_succeed(
422 client: AsyncClient, db_session: AsyncSession
423 ) -> None:
424 """10 sequential logins, each with a fresh challenge, must all succeed.
425
426 Simulates the realistic scenario where a user logs in from the same key
427 multiple times (e.g. refreshing a session token) using a fresh challenge
428 each time. True concurrent requests share the test DB session and would
429 deadlock — the sequential variant tests the same correctness property.
430 """
431 priv, _, pub_b64, fp = _kp()
432 await _register(client, priv, pub_b64, fp, "sequential_login_user")
433
434 for i in range(10):
435 r_challenge = await client.post("/api/auth/challenge", json={"fingerprint": fp})
436 assert r_challenge.status_code == 200, f"Login {i}: challenge failed"
437 ct = r_challenge.json()["challenge_token"]
438 r_verify = await client.post("/api/auth/verify", json={
439 "challenge_token": ct,
440 "public_key_b64": pub_b64,
441 "signature_b64": _sign(priv, _nonce(ct)),
442 })
443 assert r_verify.status_code == 200, f"Login {i}: verify failed: {r_verify.text}"
444 assert r_verify.json()["handle"] == "sequential_login_user"
445
446
447 # ---------------------------------------------------------------------------
448 # No 500 errors anywhere
449 # ---------------------------------------------------------------------------
450
451
452 @pytest.mark.parametrize("endpoint,payload", [
453 ("/api/auth/challenge", {}),
454 ("/api/auth/challenge", {"fingerprint": None}),
455 ("/api/auth/challenge", {"fingerprint": 12345}),
456 ("/api/auth/verify", {}),
457 ("/api/auth/verify", {"challenge_token": None, "public_key_b64": None, "signature_b64": None}),
458 ("/api/auth/verify", {"challenge_token": "", "public_key_b64": "", "signature_b64": ""}),
459 ])
460 async def test_garbage_inputs_never_cause_500(
461 endpoint: str,
462 payload: JSONObject,
463 client: AsyncClient,
464 db_session: AsyncSession,
465 ) -> None:
466 """Every garbage input must return 4xx, never 5xx."""
467 resp = await client.post(endpoint, json=payload)
468 assert resp.status_code < 500, (
469 f"POST {endpoint} with {payload!r} returned {resp.status_code}: {resp.text}"
470 )
File History 12 commits
sha256:cfefc25a166c3c3eed8ea3529aee19ea350bc05f2954d007420e924133b7d8ce chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 13 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago