@@ -192,3 +192,84 @@ async def test_unsigned_publish_still_works(hub_port):
192192 break
193193 await asyncio .sleep (0.1 )
194194 assert pub .api_key , "unsigned manifest should still register (backwards compat)"
195+
196+
197+ # --- key-pinning unit tests (direct Hub.register_publisher) -----------------
198+ # The public_key is served openly at /<name>/manifest.json, so pinning must
199+ # require a *valid signature* on re-registration, not just a matching
200+ # public_key. These drive the registration logic directly without a live hub.
201+
202+ def _new_storage ():
203+ import os
204+ import tempfile
205+ from zhub .persistence import Storage
206+ fd , db = tempfile .mkstemp (suffix = ".db" )
207+ os .close (fd )
208+ return Storage (db )
209+
210+
211+ @pytest .mark .asyncio
212+ async def test_pinning_rejects_unsigned_reregister_with_copied_public_key ():
213+ """Stolen api_key + the (public) public_key, but no signature, must NOT
214+ take over a signed registration."""
215+ if not SERVER_AVAILABLE :
216+ pytest .skip ("fastapi/uvicorn not installed" )
217+ from zhub .server import Hub
218+ hub = Hub (storage = _new_storage ())
219+ sk , pk = generate_keypair ()
220+ signed = sign_manifest (
221+ {"name" : "victim" , "description" : "real" , "schema_version" : "0.1" , "capabilities" : []}, sk
222+ )
223+ _ , api_key = await hub .register_publisher ("victim" , signed , websocket = None )
224+
225+ forged = {"name" : "victim" , "description" : "PWNED" , "schema_version" : "0.1" ,
226+ "capabilities" : [], "public_key" : pk } # copied public key, no signature
227+ with pytest .raises (PermissionError , match = "must be signed" ):
228+ await hub .register_publisher ("victim" , forged , websocket = None , desired_api_key = api_key )
229+ assert hub .publishers ["victim" ].manifest ["description" ] == "real"
230+
231+
232+ @pytest .mark .asyncio
233+ async def test_pinning_rejects_reregister_signed_with_different_key ():
234+ """A validly-signed manifest under a *different* keypair must not take over
235+ a registration pinned to the original key."""
236+ if not SERVER_AVAILABLE :
237+ pytest .skip ("fastapi/uvicorn not installed" )
238+ from zhub .server import Hub
239+ hub = Hub (storage = _new_storage ())
240+ sk , _pk = generate_keypair ()
241+ signed = sign_manifest (
242+ {"name" : "victim" , "description" : "real" , "schema_version" : "0.1" , "capabilities" : []}, sk
243+ )
244+ _ , api_key = await hub .register_publisher ("victim" , signed , websocket = None )
245+
246+ attacker_sk , _ = generate_keypair ()
247+ other = sign_manifest (
248+ {"name" : "victim" , "description" : "PWNED" , "schema_version" : "0.1" , "capabilities" : []},
249+ attacker_sk ,
250+ )
251+ with pytest .raises (PermissionError , match = "public_key does not match" ):
252+ await hub .register_publisher ("victim" , other , websocket = None , desired_api_key = api_key )
253+ assert hub .publishers ["victim" ].manifest ["description" ] == "real"
254+
255+
256+ @pytest .mark .asyncio
257+ async def test_pinning_allows_legit_resigned_reregister ():
258+ """The genuine owner re-registers with their own freshly-signed manifest
259+ (as the publish client does on every reconnect) and keeps the same key."""
260+ if not SERVER_AVAILABLE :
261+ pytest .skip ("fastapi/uvicorn not installed" )
262+ from zhub .server import Hub
263+ hub = Hub (storage = _new_storage ())
264+ sk , _pk = generate_keypair ()
265+ signed = sign_manifest (
266+ {"name" : "victim" , "description" : "v1" , "schema_version" : "0.1" , "capabilities" : []}, sk
267+ )
268+ _ , api_key = await hub .register_publisher ("victim" , signed , websocket = None )
269+
270+ resigned = sign_manifest (
271+ {"name" : "victim" , "description" : "v2" , "schema_version" : "0.1" , "capabilities" : []}, sk
272+ )
273+ _ , key2 = await hub .register_publisher ("victim" , resigned , websocket = None , desired_api_key = api_key )
274+ assert key2 == api_key
275+ assert hub .publishers ["victim" ].manifest ["description" ] == "v2"
0 commit comments