From 9a12ff169305ca8e768a27170cd6e433c2c876dc Mon Sep 17 00:00:00 2001 From: Thykof Date: Tue, 16 Jun 2026 19:22:47 +0200 Subject: [PATCH 1/4] fix(axon): reject requests with missing signature in default_verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit default_verify gated the signature check behind `if synapse.dendrite.signature`, so a request with an empty or absent signature skipped verification entirely. This lets anyone impersonate any dendrite hotkey (e.g. a high-stake validator) by simply omitting the signature — the request then passes verify and reaches blacklist/forward as if it were genuinely signed. Require a signature to be present, mirroring the existing "Missing Nonce" guard, then verify it. A present-but-invalid signature was already rejected; this closes the empty-signature hole. --- bittensor/core/axon.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bittensor/core/axon.py b/bittensor/core/axon.py index 3fd9dd46c2..7d5c5d1a2d 100644 --- a/bittensor/core/axon.py +++ b/bittensor/core/axon.py @@ -974,9 +974,10 @@ async def default_verify(self, synapse: "Synapse"): ): raise Exception("Nonce is too old, a newer one was last processed") - if synapse.dendrite.signature and not keypair.verify( - message, synapse.dendrite.signature - ): + if not synapse.dendrite.signature: + raise Exception("Missing signature") + + if not keypair.verify(message, synapse.dendrite.signature): raise Exception( f"Signature mismatch with {message} and {synapse.dendrite.signature}" ) From a187d2608bab83a1ef47f893efc0d904c9df56b0 Mon Sep 17 00:00:00 2001 From: Thykof Date: Tue, 16 Jun 2026 19:22:47 +0200 Subject: [PATCH 2/4] fix(axon): align case for error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit default_verify gated the signature check behind `if synapse.dendrite.signature`, so a request with an empty or absent signature skipped verification entirely. This lets anyone impersonate any dendrite hotkey (e.g. a high-stake validator) by simply omitting the signature — the request then passes verify and reaches blacklist/forward as if it were genuinely signed. Require a signature to be present, mirroring the existing "Missing Nonce" guard, then verify it. A present-but-invalid signature was already rejected; this closes the empty-signature hole. --- bittensor/core/axon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor/core/axon.py b/bittensor/core/axon.py index 7d5c5d1a2d..61573ae6fc 100644 --- a/bittensor/core/axon.py +++ b/bittensor/core/axon.py @@ -975,7 +975,7 @@ async def default_verify(self, synapse: "Synapse"): raise Exception("Nonce is too old, a newer one was last processed") if not synapse.dendrite.signature: - raise Exception("Missing signature") + raise Exception("Missing Signature") if not keypair.verify(message, synapse.dendrite.signature): raise Exception( From 2071469cbecba957d0ae5c22b42c1caf5d281757 Mon Sep 17 00:00:00 2001 From: Thykof Date: Thu, 18 Jun 2026 15:44:27 +0200 Subject: [PATCH 3/4] test(axon): cover missing/empty signature in default_verify Exercise Axon.default_verify directly: a valid signature passes (nonce recorded), an empty or absent signature now raises "Missing Signature" (the auth-bypass regression this branch fixes), and a present-but-invalid signature still raises "Signature mismatch". Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit_tests/test_axon.py | 81 ++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_axon.py b/tests/unit_tests/test_axon.py index d1b8e03a97..75c3bd4f11 100644 --- a/tests/unit_tests/test_axon.py +++ b/tests/unit_tests/test_axon.py @@ -21,7 +21,7 @@ from bittensor.core.errors import RunException from bittensor.core.settings import version_as_int from bittensor.core.stream import StreamingSynapse -from bittensor.core.synapse import Synapse +from bittensor.core.synapse import Synapse, TerminalInfo from bittensor.core.threadpool import PriorityThreadPoolExecutor from bittensor.utils.axon_utils import ( allowed_nonce_window_ns, @@ -29,6 +29,7 @@ ALLOWED_DELTA, NANOSECONDS_IN_SECOND, ) +from bittensor_wallet import Keypair def test_attach_initial(mock_get_external_ip): @@ -755,6 +756,84 @@ def test_nonce_diff_seconds(nonce_offset_seconds): ) +# --------------------------------------------------------------------------- +# default_verify signature checks +# --------------------------------------------------------------------------- +def _make_default_verify_inputs(): + """Build an (axon, synapse, dendrite_keypair) trio for default_verify. + + The synapse carries a fresh nonce (so the v7.2 freshness check passes) and + a valid dendrite hotkey; each test sets `synapse.dendrite.signature` to + probe the signature branch. + """ + dendrite_keypair = Keypair.create_from_uri("//Alice") + receiver_keypair = Keypair.create_from_uri("//Bob") + axon = Axon( + ip="192.0.2.1", + external_ip="192.0.2.1", + wallet=MockWallet(MockHotkey(receiver_keypair.ss58_address)), + ) + synapse = SynapseMock() + synapse.dendrite = TerminalInfo( + hotkey=dendrite_keypair.ss58_address, + nonce=time.time_ns(), + uuid="5ecbd69c-1cec-11ee-b0dc-e29ce36fec1a", + version=version_as_int, + ) + return axon, synapse, dendrite_keypair + + +def _default_verify_message(axon, synapse): + return ( + f"{synapse.dendrite.nonce}.{synapse.dendrite.hotkey}." + f"{axon.wallet.hotkey.ss58_address}.{synapse.dendrite.uuid}." + f"{synapse.computed_body_hash}" + ) + + +@pytest.mark.asyncio +async def test_default_verify_valid_signature_passes(): + axon, synapse, dendrite_keypair = _make_default_verify_inputs() + message = _default_verify_message(axon, synapse) + synapse.dendrite.signature = "0x" + dendrite_keypair.sign(message).hex() + + await axon.default_verify(synapse) + + endpoint_key = f"{synapse.dendrite.hotkey}:{synapse.dendrite.uuid}" + assert axon.nonces[endpoint_key] == synapse.dendrite.nonce + + +@pytest.mark.asyncio +async def test_default_verify_empty_signature_raises(): + # Regression: an empty signature must NOT skip verification. Previously the + # check was `if signature and not verify(...)`, so an empty signature + # short-circuited and the request was accepted, allowing hotkey spoofing. + axon, synapse, _ = _make_default_verify_inputs() + synapse.dendrite.signature = "" + + with pytest.raises(Exception, match="Missing Signature"): + await axon.default_verify(synapse) + + +@pytest.mark.asyncio +async def test_default_verify_missing_signature_raises(): + axon, synapse, _ = _make_default_verify_inputs() + synapse.dendrite.signature = None + + with pytest.raises(Exception, match="Missing Signature"): + await axon.default_verify(synapse) + + +@pytest.mark.asyncio +async def test_default_verify_wrong_signature_raises(): + # A present-but-invalid signature is still rejected (unchanged behavior). + axon, synapse, _ = _make_default_verify_inputs() + synapse.dendrite.signature = "0x" + ("00" * 64) + + with pytest.raises(Exception, match="Signature mismatch"): + await axon.default_verify(synapse) + + # Mimicking axon default_verify nonce verification # True: Nonce is fresh, False: Nonce is old def is_nonce_within_allowed_window(synapse_nonce, allowed_window_ns): From 49260fe1a2b3b95488b126b12bf0e78d65a349ae Mon Sep 17 00:00:00 2001 From: Thykof Date: Fri, 19 Jun 2026 12:35:02 +0200 Subject: [PATCH 4/4] test(axon): merge empty/None signature tests into single parametrized test Co-Authored-By: Claude Sonnet 4.6 --- tests/unit_tests/test_axon.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/tests/unit_tests/test_axon.py b/tests/unit_tests/test_axon.py index 75c3bd4f11..0712e50dc1 100644 --- a/tests/unit_tests/test_axon.py +++ b/tests/unit_tests/test_axon.py @@ -804,21 +804,13 @@ async def test_default_verify_valid_signature_passes(): @pytest.mark.asyncio -async def test_default_verify_empty_signature_raises(): - # Regression: an empty signature must NOT skip verification. Previously the - # check was `if signature and not verify(...)`, so an empty signature +@pytest.mark.parametrize("empty_sig", ["", None]) +async def test_default_verify_missing_signature_raises(empty_sig): + # Regression: a falsy signature must NOT skip verification. Previously the + # check was `if signature and not verify(...)`, so an empty/None signature # short-circuited and the request was accepted, allowing hotkey spoofing. axon, synapse, _ = _make_default_verify_inputs() - synapse.dendrite.signature = "" - - with pytest.raises(Exception, match="Missing Signature"): - await axon.default_verify(synapse) - - -@pytest.mark.asyncio -async def test_default_verify_missing_signature_raises(): - axon, synapse, _ = _make_default_verify_inputs() - synapse.dendrite.signature = None + synapse.dendrite.signature = empty_sig with pytest.raises(Exception, match="Missing Signature"): await axon.default_verify(synapse)