Skip to content

Commit b9c3dbc

Browse files
committed
Allow local uploads from unregistered hotkeys
1 parent 064b544 commit b9c3dbc

2 files changed

Lines changed: 245 additions & 1 deletion

File tree

src/platform_network/security/miner_auth.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,9 @@ def _uid_for_hotkey(self, hotkey: str) -> int | None:
205205
return None
206206
uid = hotkey_to_uid.get(hotkey)
207207
if uid is None:
208-
raise MinerAuthError("unknown hotkey")
208+
if self.require_registered_hotkey:
209+
raise MinerAuthError("unknown hotkey")
210+
return None
209211
if uid in self.blocked_uids:
210212
raise MinerAuthError("blocked uid")
211213
return uid

tests/unit/test_admin_registry_proxy.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,6 +1260,248 @@ async def handler(request: httpx.Request) -> httpx.Response:
12601260
assert stale_response.status_code == 401
12611261

12621262

1263+
def test_signed_upload_bridge_strict_rejects_unknown_hotkey() -> None:
1264+
registry = ChallengeRegistry()
1265+
registry.create(ChallengeCreate(**_payload()))
1266+
registry.set_status("demo", ChallengeStatus.ACTIVE)
1267+
1268+
class Cache:
1269+
def get(self) -> dict[str, int]:
1270+
return {"known": 7}
1271+
1272+
verifier = MinerUploadVerifier(
1273+
netuid=42,
1274+
nonce_store=FakeNonceStore(),
1275+
metagraph_cache=Cache(), # type: ignore[arg-type]
1276+
now_fn=lambda: 1_000,
1277+
signature_verifier=lambda _hotkey, _message, signature: signature == "valid",
1278+
)
1279+
1280+
@asynccontextmanager
1281+
async def failing_client_factory():
1282+
async def handler(request: httpx.Request) -> httpx.Response:
1283+
raise httpx.ConnectError("unexpected upstream call", request=request)
1284+
1285+
transport = httpx.MockTransport(handler)
1286+
async with httpx.AsyncClient(transport=transport) as client:
1287+
yield client
1288+
1289+
client = TestClient(
1290+
create_proxy_app(
1291+
registry=registry,
1292+
client_factory=failing_client_factory,
1293+
miner_verifier=verifier,
1294+
challenge_token_provider=lambda _slug: "challenge-token",
1295+
)
1296+
)
1297+
1298+
response = client.post(
1299+
"/v1/challenges/demo/submissions",
1300+
content=b"zip-bytes",
1301+
headers={
1302+
"X-Hotkey": "unknown",
1303+
"X-Signature": "valid",
1304+
"X-Nonce": "nonce-strict-unknown",
1305+
"X-Timestamp": "1000",
1306+
},
1307+
)
1308+
1309+
assert response.status_code == 401
1310+
assert response.json() == {"detail": "unknown hotkey"}
1311+
1312+
1313+
def test_signed_upload_bridge_bypass_forwards_unknown_without_uid() -> None:
1314+
registry = ChallengeRegistry()
1315+
registry.create(ChallengeCreate(**_payload()))
1316+
registry.set_status("demo", ChallengeStatus.ACTIVE)
1317+
captured: dict[str, Any] = {}
1318+
1319+
class Cache:
1320+
def get(self) -> dict[str, int]:
1321+
return {"known": 7}
1322+
1323+
challenge_app = FastAPI()
1324+
1325+
@challenge_app.post("/internal/v1/bridge/submissions")
1326+
async def bridge(request: Request) -> dict[str, object]:
1327+
captured["headers"] = dict(request.headers)
1328+
captured["body"] = await request.body()
1329+
return {"id": "sub-bypass", "status": "pending"}
1330+
1331+
@asynccontextmanager
1332+
async def client_factory():
1333+
transport = httpx.ASGITransport(app=challenge_app)
1334+
async with httpx.AsyncClient(
1335+
transport=transport, base_url="http://challenge-demo:8000"
1336+
) as client:
1337+
yield client
1338+
1339+
verifier = MinerUploadVerifier(
1340+
netuid=42,
1341+
nonce_store=FakeNonceStore(),
1342+
metagraph_cache=Cache(), # type: ignore[arg-type]
1343+
now_fn=lambda: 1_000,
1344+
require_registered_hotkey=False,
1345+
signature_verifier=lambda _hotkey, _message, signature: signature == "valid",
1346+
)
1347+
client = TestClient(
1348+
create_proxy_app(
1349+
registry=registry,
1350+
client_factory=client_factory,
1351+
miner_verifier=verifier,
1352+
challenge_token_provider=lambda _slug: "challenge-token",
1353+
)
1354+
)
1355+
1356+
response = client.post(
1357+
"/v1/challenges/demo/submissions",
1358+
content=b"zip-bytes",
1359+
headers={
1360+
"Content-Type": "application/zip",
1361+
"X-Hotkey": "unknown",
1362+
"X-Signature": "valid",
1363+
"X-Nonce": "nonce-bypass-unknown",
1364+
"X-Timestamp": "1000",
1365+
"X-Platform-Verified-Uid": "spoof",
1366+
},
1367+
)
1368+
1369+
assert response.status_code == 200
1370+
assert response.json() == {"id": "sub-bypass", "status": "pending"}
1371+
headers = captured["headers"]
1372+
assert headers["authorization"] == "Bearer challenge-token"
1373+
assert headers["x-platform-verified-hotkey"] == "unknown"
1374+
assert headers["x-platform-verified-nonce"] == "nonce-bypass-unknown"
1375+
assert "x-platform-verified-uid" not in headers
1376+
assert captured["body"] == b"zip-bytes"
1377+
1378+
1379+
def test_signed_upload_bridge_disabled_registration_rejects_blocked_uid() -> None:
1380+
registry = ChallengeRegistry()
1381+
registry.create(ChallengeCreate(**_payload()))
1382+
registry.set_status("demo", ChallengeStatus.ACTIVE)
1383+
1384+
class Cache:
1385+
def get(self) -> dict[str, int]:
1386+
return {"known": 0}
1387+
1388+
verifier = MinerUploadVerifier(
1389+
netuid=42,
1390+
nonce_store=FakeNonceStore(),
1391+
metagraph_cache=Cache(), # type: ignore[arg-type]
1392+
now_fn=lambda: 1_000,
1393+
require_registered_hotkey=False,
1394+
signature_verifier=lambda _hotkey, _message, signature: signature == "valid",
1395+
)
1396+
1397+
@asynccontextmanager
1398+
async def failing_client_factory():
1399+
async def handler(request: httpx.Request) -> httpx.Response:
1400+
raise httpx.ConnectError("unexpected upstream call", request=request)
1401+
1402+
transport = httpx.MockTransport(handler)
1403+
async with httpx.AsyncClient(transport=transport) as client:
1404+
yield client
1405+
1406+
client = TestClient(
1407+
create_proxy_app(
1408+
registry=registry,
1409+
client_factory=failing_client_factory,
1410+
miner_verifier=verifier,
1411+
challenge_token_provider=lambda _slug: "challenge-token",
1412+
)
1413+
)
1414+
1415+
response = client.post(
1416+
"/v1/challenges/demo/submissions",
1417+
content=b"zip-bytes",
1418+
headers={
1419+
"X-Hotkey": "known",
1420+
"X-Signature": "valid",
1421+
"X-Nonce": "nonce-blocked",
1422+
"X-Timestamp": "1000",
1423+
},
1424+
)
1425+
1426+
assert response.status_code == 401
1427+
assert response.json() == {"detail": "blocked uid"}
1428+
1429+
1430+
def test_signed_upload_bridge_bypass_keeps_signature_time_replay_guards() -> None:
1431+
registry = ChallengeRegistry()
1432+
registry.create(ChallengeCreate(**_payload()))
1433+
registry.set_status("demo", ChallengeStatus.ACTIVE)
1434+
1435+
class Cache:
1436+
def get(self) -> dict[str, int]:
1437+
return {"known": 7}
1438+
1439+
verifier = MinerUploadVerifier(
1440+
netuid=42,
1441+
nonce_store=FakeNonceStore(),
1442+
metagraph_cache=Cache(), # type: ignore[arg-type]
1443+
now_fn=lambda: 1_000,
1444+
require_registered_hotkey=False,
1445+
signature_verifier=lambda _hotkey, _message, signature: signature == "valid",
1446+
)
1447+
1448+
@asynccontextmanager
1449+
async def failing_client_factory():
1450+
async def handler(request: httpx.Request) -> httpx.Response:
1451+
raise httpx.ConnectError("boom", request=request)
1452+
1453+
transport = httpx.MockTransport(handler)
1454+
async with httpx.AsyncClient(transport=transport) as client:
1455+
yield client
1456+
1457+
client = TestClient(
1458+
create_proxy_app(
1459+
registry=registry,
1460+
client_factory=failing_client_factory,
1461+
miner_verifier=verifier,
1462+
challenge_token_provider=lambda _slug: "challenge-token",
1463+
)
1464+
)
1465+
headers = {
1466+
"X-Hotkey": "unknown",
1467+
"X-Signature": "valid",
1468+
"X-Nonce": "nonce-bypass-replay",
1469+
"X-Timestamp": "1000",
1470+
}
1471+
1472+
invalid_signature = client.post(
1473+
"/v1/challenges/demo/submissions",
1474+
content=b"zip-bytes",
1475+
headers={**headers, "X-Signature": "invalid", "X-Nonce": "nonce-invalid"},
1476+
)
1477+
assert invalid_signature.status_code == 401
1478+
assert invalid_signature.json() == {"detail": "invalid signature"}
1479+
1480+
stale = client.post(
1481+
"/v1/challenges/demo/submissions",
1482+
content=b"zip-bytes",
1483+
headers={**headers, "X-Nonce": "nonce-stale", "X-Timestamp": "1"},
1484+
)
1485+
assert stale.status_code == 401
1486+
assert stale.json() == {"detail": "stale signature"}
1487+
1488+
first = client.post(
1489+
"/v1/challenges/demo/submissions",
1490+
content=b"zip-bytes",
1491+
headers=headers,
1492+
)
1493+
assert first.status_code == 502
1494+
assert first.json() == {"detail": "Challenge unavailable"}
1495+
1496+
replay = client.post(
1497+
"/v1/challenges/demo/submissions",
1498+
content=b"zip-bytes",
1499+
headers=headers,
1500+
)
1501+
assert replay.status_code == 409
1502+
assert replay.json() == {"detail": "nonce already used"}
1503+
1504+
12631505
def test_production_admin_rejects_unsafe_challenge_image() -> None:
12641506
registry = ChallengeRegistry()
12651507
client = TestClient(

0 commit comments

Comments
 (0)