gabriel / musehub public
test_auth_dead_identity_recovery.py python
218 lines 8.7 KB
Raw
sha256:c3c2cb91e8fb6b8235f67868620336ac3e425529430280edb6ec79a47258525f fix: dead identity recovery — allow re-keying a handle with… Sonnet 4.6 patch 20 days ago
1 """Dead identity recovery — handle exists but has no registered keys.
2
3 Scenario that breaks aaronrene and edwin:
4 1. User registered successfully (identity + key created)
5 2. Key was deleted from musehub_auth_keys (logout, failed rotation, etc.)
6 3. Identity row remains in musehub_identities — handle is "taken"
7 4. User tries to authenticate → 401 "No registered keys for identity"
8 5. User tries to re-register with same handle → 409 "Handle already taken"
9 6. User is locked out with no recovery path
10
11 Fix: when a new key attempts to register under a handle that exists but has
12 no keys (dead identity), add the new key to the existing identity instead of
13 raising 409.
14
15 Coverage
16 --------
17 D1 Dead identity → auth returns 401 "No registered keys for identity"
18 D2 Dead identity + re-register with same handle → 200 (recovery succeeds)
19 D3 Dead identity recovery → subsequent auth with new key succeeds
20 D4 Active identity (has keys) + different key cannot steal the handle → 409
21 D5 Active identity (has keys) + same key → 200 (normal login)
22 """
23 from __future__ import annotations
24
25 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
26 from httpx import AsyncClient
27 from sqlalchemy import delete, select
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 import pytest
31
32 from muse.core.types import encode_pubkey, encode_sig, public_key_fingerprint
33 from musehub.core.genesis import compute_identity_id
34 from musehub.db.musehub_auth_models import MusehubAuthKey
35 from musehub.db.musehub_identity_models import MusehubIdentity
36
37
38 # ---------------------------------------------------------------------------
39 # Helpers
40 # ---------------------------------------------------------------------------
41
42 def _kp() -> tuple[Ed25519PrivateKey, bytes, str, str]:
43 """Return (priv, raw_pub_bytes, pub_b64, fingerprint)."""
44 priv = Ed25519PrivateKey.generate()
45 raw = priv.public_key().public_bytes_raw()
46 return priv, raw, encode_pubkey("ed25519", raw), public_key_fingerprint(raw)
47
48
49 def _sign(priv: Ed25519PrivateKey, nonce_hex: str) -> str:
50 return encode_sig("ed25519", priv.sign(bytes.fromhex(nonce_hex)))
51
52
53 async def _register(client: AsyncClient, priv: Ed25519PrivateKey, pub_b64: str, fp: str, handle: str) -> int:
54 r1 = await client.post("/api/auth/challenge", json={"fingerprint": fp})
55 assert r1.status_code == 200, r1.text
56 ct = r1.json()["challenge_token"]
57 r2 = await client.post("/api/auth/verify", json={
58 "challenge_token": ct,
59 "public_key_b64": pub_b64,
60 "signature_b64": _sign(priv, ct),
61 "handle": handle,
62 })
63 return r2.status_code
64
65
66 async def _auth(client: AsyncClient, priv: Ed25519PrivateKey, pub_b64: str, fp: str, handle: str) -> int:
67 r1 = await client.post("/api/auth/challenge", json={"fingerprint": fp})
68 assert r1.status_code == 200, r1.text
69 ct = r1.json()["challenge_token"]
70 r2 = await client.post("/api/auth/verify", json={
71 "challenge_token": ct,
72 "public_key_b64": pub_b64,
73 "signature_b64": _sign(priv, ct),
74 "handle": handle,
75 })
76 return r2.status_code
77
78
79 async def _make_dead_identity(
80 client: AsyncClient,
81 db_session: AsyncSession,
82 handle: str,
83 ) -> tuple[Ed25519PrivateKey, str, str]:
84 """Register a user then delete their key, leaving a dead identity.
85
86 Returns (original_priv, original_pub_b64, original_fp).
87 """
88 priv, _, pub_b64, fp = _kp()
89 code = await _register(client, priv, pub_b64, fp, handle)
90 assert code == 200, f"setup registration failed: {code}"
91
92 # Delete the key row — simulates logout / failed rotation
93 await db_session.execute(
94 delete(MusehubAuthKey).where(MusehubAuthKey.fingerprint == fp)
95 )
96 await db_session.commit()
97 db_session.expire_all()
98
99 # Verify the identity still exists but has no keys
100 identity = (await db_session.execute(
101 select(MusehubIdentity).where(MusehubIdentity.handle == handle)
102 )).scalar_one_or_none()
103 assert identity is not None, "identity should still exist after key deletion"
104 key_count = (await db_session.execute(
105 select(MusehubAuthKey).where(MusehubAuthKey.identity_id == identity.identity_id)
106 )).scalars().all()
107 assert len(key_count) == 0, "dead identity must have zero keys"
108
109 return priv, pub_b64, fp
110
111
112 # ---------------------------------------------------------------------------
113 # D1 Dead identity → re-register same handle with NEW key → 200 (recovery)
114 # ---------------------------------------------------------------------------
115
116 async def test_D1_dead_identity_blocks_reregister_without_fix(
117 client: AsyncClient,
118 db_session: AsyncSession,
119 ) -> None:
120 """D1: With an active identity that still has keys, a different key → 409.
121
122 This is the NEGATIVE case — proves the guard still works when there ARE
123 active keys. The recovery path (D2) only fires when there are zero keys.
124 """
125 handle = "dead-d1-user"
126 priv, _, pub_b64, fp = _kp()
127 code = await _register(client, priv, pub_b64, fp, handle)
128 assert code == 200, f"setup failed: {code}"
129
130 # Different key, same handle — identity has an active key → must be 409
131 other_priv, _, other_pub_b64, other_fp = _kp()
132 code2 = await _register(client, other_priv, other_pub_b64, other_fp, handle)
133 assert code2 == 409, (
134 f"Active identity should not allow a different key: expected 409, got {code2}"
135 )
136
137
138 # ---------------------------------------------------------------------------
139 # D2 Dead identity + re-register same handle → 200 (recovery)
140 # ---------------------------------------------------------------------------
141
142 async def test_D2_dead_identity_reregister_succeeds(
143 client: AsyncClient,
144 db_session: AsyncSession,
145 ) -> None:
146 """D2: Re-registering a new key under a dead identity's handle → 200."""
147 handle = "dead-d2-user"
148 await _make_dead_identity(client, db_session, handle)
149
150 # New key — different from the one that was deleted
151 new_priv, _, new_pub_b64, new_fp = _kp()
152 code = await _register(client, new_priv, new_pub_b64, new_fp, handle)
153 assert code == 200, (
154 f"Dead identity recovery should return 200, got {code}. "
155 "Server should allow re-keying a handle with no active keys."
156 )
157
158
159 # ---------------------------------------------------------------------------
160 # D3 After recovery, new key authenticates successfully
161 # ---------------------------------------------------------------------------
162
163 async def test_D3_recovered_identity_auth_succeeds(
164 client: AsyncClient,
165 db_session: AsyncSession,
166 ) -> None:
167 """D3: After dead identity recovery, the new key can authenticate."""
168 handle = "dead-d3-user"
169 await _make_dead_identity(client, db_session, handle)
170
171 new_priv, _, new_pub_b64, new_fp = _kp()
172 reg_code = await _register(client, new_priv, new_pub_b64, new_fp, handle)
173 assert reg_code == 200, f"recovery registration failed: {reg_code}"
174
175 auth_code = await _auth(client, new_priv, new_pub_b64, new_fp, handle)
176 assert auth_code == 200, (
177 f"Expected 200 for auth after recovery, got {auth_code}"
178 )
179
180
181 # ---------------------------------------------------------------------------
182 # D4 Active identity (has keys) → different key cannot steal handle → 409
183 # ---------------------------------------------------------------------------
184
185 async def test_D4_active_identity_cannot_be_stolen(
186 client: AsyncClient,
187 db_session: AsyncSession,
188 ) -> None:
189 """D4: A handle with active keys cannot be claimed by a different key."""
190 handle = "dead-d4-user"
191 priv, _, pub_b64, fp = _kp()
192 code = await _register(client, priv, pub_b64, fp, handle)
193 assert code == 200, f"setup failed: {code}"
194
195 # Different key tries to register under the same handle
196 other_priv, _, other_pub_b64, other_fp = _kp()
197 steal_code = await _register(client, other_priv, other_pub_b64, other_fp, handle)
198 assert steal_code == 409, (
199 f"Expected 409 when handle with active keys is reused by a different key, got {steal_code}"
200 )
201
202
203 # ---------------------------------------------------------------------------
204 # D5 Active identity + same key → 200 (normal login / idempotent re-register)
205 # ---------------------------------------------------------------------------
206
207 async def test_D5_active_identity_same_key_login(
208 client: AsyncClient,
209 db_session: AsyncSession,
210 ) -> None:
211 """D5: Re-registering with the same key under the same handle → 200 (login)."""
212 handle = "dead-d5-user"
213 priv, _, pub_b64, fp = _kp()
214 code1 = await _register(client, priv, pub_b64, fp, handle)
215 assert code1 == 200, f"first registration failed: {code1}"
216
217 code2 = await _auth(client, priv, pub_b64, fp, handle)
218 assert code2 == 200, f"second auth (same key) failed: {code2}"
File History 1 commit
sha256:c3c2cb91e8fb6b8235f67868620336ac3e425529430280edb6ec79a47258525f fix: dead identity recovery — allow re-keying a handle with… Sonnet 4.6 patch 20 days ago