From a51cc7ca5cafbac5130a5fb66460633d5c8d3965 Mon Sep 17 00:00:00 2001 From: shiyaam-s07 Date: Mon, 3 Aug 2026 13:51:19 -0400 Subject: [PATCH 1/4] fix(airdrop): verify github account ownership in claim_airdrop (#8175) --- node/airdrop_v2.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/node/airdrop_v2.py b/node/airdrop_v2.py index 5052a8376..cc17a60d6 100644 --- a/node/airdrop_v2.py +++ b/node/airdrop_v2.py @@ -793,8 +793,22 @@ def claim_airdrop( github_username = self._normalize_github_username(github_username) if not self._is_valid_github_username(github_username): return False, "Invalid GitHub username", None + # Verify ownership if a token is provided + if github_token: + try: + auth_resp = requests.get( + "https://api.github.com/user", + headers={"Accept": "application/vnd.github.v3+json", "Authorization": f"token {github_token}"}, + timeout=10, + ) + if auth_resp.status_code != 200: + return False, "Failed to verify GitHub token", None + auth_login = auth_resp.json().get("login", "").strip().casefold() + if auth_login != github_username: + return False, "GitHub token does not match provided username", None + except requests.RequestException as e: + return False, f"GitHub verification error: {e}", None chain_lower = chain.lower() - if self._has_claimed(github_username, wallet_address, chain_lower): return False, "Claim already exists for this GitHub account or wallet", None From 2aeb7c398e7062a0b7f7fb9cde479ed6e3b5faf3 Mon Sep 17 00:00:00 2001 From: shiyaam-s07 Date: Tue, 4 Aug 2026 09:23:26 -0400 Subject: [PATCH 2/4] fix(airdrop): add User-Agent header and sanitize exception error message --- node/airdrop_v2.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/node/airdrop_v2.py b/node/airdrop_v2.py index cc17a60d6..bb9d75c5b 100644 --- a/node/airdrop_v2.py +++ b/node/airdrop_v2.py @@ -4,6 +4,7 @@ Implements RIP-305: Cross-Chain Airdrop for wRTC on Solana + Base + Tracks: A: Solana SPL Token (wRTC) B: Base ERC-20 Token (wRTC) @@ -37,6 +38,7 @@ import math import os import re +import requests import sqlite3 import time from dataclasses import dataclass, asdict @@ -798,7 +800,7 @@ def claim_airdrop( try: auth_resp = requests.get( "https://api.github.com/user", - headers={"Accept": "application/vnd.github.v3+json", "Authorization": f"token {github_token}"}, + headers={"Accept": "application/vnd.github.v3+json", "Authorization": f"token {github_token}", "User-Agent": "RustChain-Airdrop-Verifier"} timeout=10, ) if auth_resp.status_code != 200: @@ -806,8 +808,8 @@ def claim_airdrop( auth_login = auth_resp.json().get("login", "").strip().casefold() if auth_login != github_username: return False, "GitHub token does not match provided username", None - except requests.RequestException as e: - return False, f"GitHub verification error: {e}", None + except requests.RequestException: + return False, "GitHub verification failed", None chain_lower = chain.lower() if self._has_claimed(github_username, wallet_address, chain_lower): return False, "Claim already exists for this GitHub account or wallet", None From cf4122d7a7973448dae213b75324536abdeb0c69 Mon Sep 17 00:00:00 2001 From: shiyaam-s07 Date: Tue, 4 Aug 2026 14:18:07 -0400 Subject: [PATCH 3/4] fix(security): enforce Ed25519 verification, hide escrow_secret, and fix tests (#8179) --- node/airdrop_v2.py | 2 +- node/gpu_render_protocol.py | 21 +++++++ tests/test_gpu_render_protocol.py | 91 +++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/node/airdrop_v2.py b/node/airdrop_v2.py index bb9d75c5b..4474e366e 100644 --- a/node/airdrop_v2.py +++ b/node/airdrop_v2.py @@ -800,7 +800,7 @@ def claim_airdrop( try: auth_resp = requests.get( "https://api.github.com/user", - headers={"Accept": "application/vnd.github.v3+json", "Authorization": f"token {github_token}", "User-Agent": "RustChain-Airdrop-Verifier"} + headers={"Accept": "application/vnd.github.v3+json", "Authorization": f"token {github_token}", "User-Agent": "RustChain-Airdrop-Verifier"}, timeout=10, ) if auth_resp.status_code != 200: diff --git a/node/gpu_render_protocol.py b/node/gpu_render_protocol.py index c9e036633..6c80cf0ef 100644 --- a/node/gpu_render_protocol.py +++ b/node/gpu_render_protocol.py @@ -645,6 +645,25 @@ def create_escrow(): if metadata is not None and not isinstance(metadata, dict): return jsonify({"error": "metadata must be an object"}), 400 + # ---- Security checks ---- + # Require Ed25519 signature over a message to authenticate from_wallet. + signature_hex, sig_err = _string_field(data, "signature") + if sig_err is not None: + return sig_err + message, msg_err = _string_field(data, "message") + if msg_err is not None: + return msg_err + + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + pub_bytes = bytes.fromhex(from_wallet) + sig_bytes = bytes.fromhex(signature_hex) + Ed25519PublicKey.from_public_bytes(pub_bytes).verify(sig_bytes, message.encode()) + except Exception as e: + logger.warning("Ed25519 signature verification failed: %s", e) + return jsonify({"error": "Invalid or missing signature for from_wallet"}), 401 + # End of security checks + result = protocol.create_escrow( job_type=job_type, from_wallet=from_wallet, @@ -652,6 +671,8 @@ def create_escrow(): amount_rtc=amount_rtc, metadata=metadata, ) + # Remove escrow_secret from API response to avoid leakage + result.pop("escrow_secret", None) status_code = 201 if "error" not in result else 400 return jsonify(result), status_code diff --git a/tests/test_gpu_render_protocol.py b/tests/test_gpu_render_protocol.py index 46d8b61c7..fababb1f1 100644 --- a/tests/test_gpu_render_protocol.py +++ b/tests/test_gpu_render_protocol.py @@ -394,5 +394,96 @@ def test_gpu_protocol_attest_accepts_api_key_header(tmp_path, monkeypatch): assert nodes["count"] == 1 +def test_gpu_protocol_escrow_valid_signature(tmp_path, monkeypatch): + client = _route_client(tmp_path, monkeypatch) + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + priv = Ed25519PrivateKey.generate() + pub = priv.public_key() + + # We serialize the public key natively (often 32 bytes for Ed25519) + from cryptography.hazmat.primitives import serialization + pub_bytes = pub.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw + ) + pub_hex = pub_bytes.hex() + message = "test_nonce_timestamp" + sig = priv.sign(message.encode()) + sig_hex = sig.hex() + + response = client.post( + "/render/escrow", + json={ + "job_type": "render", + "from_wallet": pub_hex, + "to_wallet": "provider", + "amount_rtc": 10.0, + "signature": sig_hex, + "message": message + } + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["status"] == "locked" + assert data["from_wallet"] == pub_hex + assert "escrow_secret" not in data + +def test_gpu_protocol_escrow_invalid_signature(tmp_path, monkeypatch): + client = _route_client(tmp_path, monkeypatch) + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives import serialization + priv = Ed25519PrivateKey.generate() + pub = priv.public_key() + pub_bytes = pub.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw + ) + pub_hex = pub_bytes.hex() + message = "test_nonce" + + priv2 = Ed25519PrivateKey.generate() + sig2 = priv2.sign(message.encode()) + + response = client.post( + "/render/escrow", + json={ + "job_type": "render", + "from_wallet": pub_hex, + "to_wallet": "provider", + "amount_rtc": 10.0, + "signature": sig2.hex(), + "message": message + } + ) + + assert response.status_code == 401 + assert response.get_json() == {"error": "Invalid or missing signature for from_wallet"} + +def test_gpu_protocol_escrow_missing_signature(tmp_path, monkeypatch): + client = _route_client(tmp_path, monkeypatch) + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives import serialization + priv = Ed25519PrivateKey.generate() + pub = priv.public_key() + pub_bytes = pub.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw + ) + pub_hex = pub_bytes.hex() + + response = client.post( + "/render/escrow", + json={ + "job_type": "render", + "from_wallet": pub_hex, + "to_wallet": "provider", + "amount_rtc": 10.0 + } + ) + + assert response.status_code == 401 + assert response.get_json() == {"error": "Invalid or missing signature for from_wallet"} + if __name__ == "__main__": unittest.main() From 65395024e531d7671b962799485d6ea77cf0745f Mon Sep 17 00:00:00 2001 From: shiyaam-s07 Date: Fri, 7 Aug 2026 15:50:02 -0400 Subject: [PATCH 4/4] fix(airdrop): resolve headers syntax error and add 10 regression tests --- node/airdrop_v2.py | 5 +- tests/test_airdrop.py | 276 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 tests/test_airdrop.py diff --git a/node/airdrop_v2.py b/node/airdrop_v2.py index 4474e366e..9e9b7fa77 100644 --- a/node/airdrop_v2.py +++ b/node/airdrop_v2.py @@ -800,7 +800,10 @@ def claim_airdrop( try: auth_resp = requests.get( "https://api.github.com/user", - headers={"Accept": "application/vnd.github.v3+json", "Authorization": f"token {github_token}", "User-Agent": "RustChain-Airdrop-Verifier"}, + headers={ + "Authorization": f"token {github_token}", + "User-Agent": "RustChain-Airdrop", + }, timeout=10, ) if auth_resp.status_code != 200: diff --git a/tests/test_airdrop.py b/tests/test_airdrop.py new file mode 100644 index 000000000..188c5ce9b --- /dev/null +++ b/tests/test_airdrop.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +""" +Regression tests for airdrop_v2.claim_airdrop GitHub ownership verification (PR #8180). + +Covers: + - Valid GitHub token + matching username → claim succeeds. + - Mismatched GitHub username → claim rejected. + - GitHub API failure / network exception → claim rejected. +""" +import os +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch, MagicMock + +# Ensure the node package is importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "node")) + +from airdrop_v2 import ( + AirdropV2, + EligibilityTier, + EligibilityResult, + ClaimRecord, +) + + +class TestGitHubOwnershipVerification(unittest.TestCase): + """Regression tests for the GitHub token ownership check in claim_airdrop (PR #8180).""" + + def setUp(self): + """Create a temporary database for each test.""" + self.temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db") + self.temp_db.close() + self.airdrop = AirdropV2(db_path=self.temp_db.name) + + def tearDown(self): + """Remove temporary database.""" + os.unlink(self.temp_db.name) + + # ------------------------------------------------------------------ + # 1. Valid token + matching GitHub username → claim succeeds + # ------------------------------------------------------------------ + @patch("airdrop_v2.requests.get") + def test_valid_token_matching_username_succeeds(self, mock_get): + """A valid GitHub PAT whose /user endpoint returns the same login + as the claim request must result in a successful claim.""" + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"login": "validuser"} + mock_get.return_value = mock_resp + + success, message, claim = self.airdrop.claim_airdrop( + github_username="validuser", + wallet_address="RTC1234567890123456789012345678901234567890", + chain="base", + tier="contributor", + github_token="ghp_validtoken123", + skip_antisybil=True, + ) + + self.assertTrue(success, f"Expected claim to succeed, got: {message}") + self.assertIsNotNone(claim) + self.assertEqual(claim.github_username, "validuser") + self.assertEqual(claim.tier, "contributor") + self.assertEqual(claim.amount_uwrtc, EligibilityTier.CONTRIBUTOR.reward_uwrtc) + + # Verify the requests.get call was made with correct args + mock_get.assert_called_once_with( + "https://api.github.com/user", + headers={ + "Authorization": "token ghp_validtoken123", + "User-Agent": "RustChain-Airdrop", + }, + timeout=10, + ) + + @patch("airdrop_v2.requests.get") + def test_valid_token_case_insensitive_username_succeeds(self, mock_get): + """GitHub username comparison should be case-insensitive (casefolded).""" + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"login": "ValidUser"} + mock_get.return_value = mock_resp + + success, message, claim = self.airdrop.claim_airdrop( + github_username="VALIDUSER", + wallet_address="RTC1234567890123456789012345678901234567890", + chain="base", + tier="contributor", + github_token="ghp_validtoken456", + skip_antisybil=True, + ) + + self.assertTrue(success, f"Expected claim to succeed, got: {message}") + self.assertIsNotNone(claim) + # Username should be normalized (casefolded) + self.assertEqual(claim.github_username, "validuser") + + # ------------------------------------------------------------------ + # 2. Mismatched GitHub username → claim rejected + # ------------------------------------------------------------------ + @patch("airdrop_v2.requests.get") + def test_mismatched_username_rejected(self, mock_get): + """When the GitHub /user endpoint returns a different login than + the one supplied in the claim, the claim must be rejected.""" + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"login": "realowner"} + mock_get.return_value = mock_resp + + success, message, claim = self.airdrop.claim_airdrop( + github_username="impersonator", + wallet_address="RTC1234567890123456789012345678901234567890", + chain="base", + tier="contributor", + github_token="ghp_stolentoken789", + skip_antisybil=True, + ) + + self.assertFalse(success) + self.assertIsNone(claim) + self.assertIn("does not match", message) + + @patch("airdrop_v2.requests.get") + def test_mismatched_username_case_aware_rejected(self, mock_get): + """Even if usernames differ only by case in a non-matching way, + a mismatch is a mismatch (tokens owned by different people).""" + mock_resp = Mock() + mock_resp.status_code = 200 + # API returns "alice", claimant says "bob" + mock_resp.json.return_value = {"login": "alice"} + mock_get.return_value = mock_resp + + success, message, claim = self.airdrop.claim_airdrop( + github_username="bob", + wallet_address="RTC1234567890123456789012345678901234567890", + chain="base", + tier="contributor", + github_token="ghp_alicetoken", + skip_antisybil=True, + ) + + self.assertFalse(success) + self.assertIsNone(claim) + self.assertIn("does not match", message) + + # ------------------------------------------------------------------ + # 3. GitHub API failure / network exception → claim rejected + # ------------------------------------------------------------------ + @patch("airdrop_v2.requests.get") + def test_github_api_non_200_rejected(self, mock_get): + """If the GitHub API returns a non-200 status (e.g. 401 Unauthorized), + the claim must be rejected with an appropriate message.""" + mock_resp = Mock() + mock_resp.status_code = 401 + mock_resp.json.return_value = {"message": "Bad credentials"} + mock_get.return_value = mock_resp + + success, message, claim = self.airdrop.claim_airdrop( + github_username="testuser", + wallet_address="RTC1234567890123456789012345678901234567890", + chain="base", + tier="contributor", + github_token="ghp_expiredtoken", + skip_antisybil=True, + ) + + self.assertFalse(success) + self.assertIsNone(claim) + self.assertIn("Failed to verify", message) + + @patch("airdrop_v2.requests.get") + def test_github_api_500_rejected(self, mock_get): + """A 500 server error from GitHub must also result in rejection.""" + mock_resp = Mock() + mock_resp.status_code = 500 + mock_get.return_value = mock_resp + + success, message, claim = self.airdrop.claim_airdrop( + github_username="testuser", + wallet_address="RTC1234567890123456789012345678901234567890", + chain="base", + tier="contributor", + github_token="ghp_anytoken", + skip_antisybil=True, + ) + + self.assertFalse(success) + self.assertIsNone(claim) + self.assertIn("Failed to verify", message) + + @patch("airdrop_v2.requests.get") + def test_network_exception_rejected(self, mock_get): + """A network-level exception (ConnectionError, Timeout, etc.) + must be caught and result in claim rejection.""" + import requests as req_lib + + mock_get.side_effect = req_lib.ConnectionError("DNS resolution failed") + + success, message, claim = self.airdrop.claim_airdrop( + github_username="testuser", + wallet_address="RTC1234567890123456789012345678901234567890", + chain="base", + tier="contributor", + github_token="ghp_anytoken", + skip_antisybil=True, + ) + + self.assertFalse(success) + self.assertIsNone(claim) + self.assertIn("verification failed", message) + + @patch("airdrop_v2.requests.get") + def test_timeout_exception_rejected(self, mock_get): + """A Timeout exception must also be handled gracefully.""" + import requests as req_lib + + mock_get.side_effect = req_lib.Timeout("Connection timed out") + + success, message, claim = self.airdrop.claim_airdrop( + github_username="testuser", + wallet_address="RTC1234567890123456789012345678901234567890", + chain="base", + tier="contributor", + github_token="ghp_anytoken", + skip_antisybil=True, + ) + + self.assertFalse(success) + self.assertIsNone(claim) + self.assertIn("verification failed", message) + + # ------------------------------------------------------------------ + # Baseline: no token provided → ownership check is skipped + # ------------------------------------------------------------------ + def test_no_token_skips_ownership_check(self): + """When no github_token is provided, the ownership verification + should be skipped entirely and the claim should proceed normally.""" + success, message, claim = self.airdrop.claim_airdrop( + github_username="testuser", + wallet_address="RTC1234567890123456789012345678901234567890", + chain="base", + tier="contributor", + github_token=None, + skip_antisybil=True, + ) + + self.assertTrue(success, f"Expected claim to succeed, got: {message}") + self.assertIsNotNone(claim) + self.assertEqual(claim.github_username, "testuser") + + @patch("airdrop_v2.requests.get") + def test_empty_login_field_rejected(self, mock_get): + """If the GitHub API returns an empty login field, the claim + must be rejected (login doesn't match any username).""" + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"login": ""} + mock_get.return_value = mock_resp + + success, message, claim = self.airdrop.claim_airdrop( + github_username="testuser", + wallet_address="RTC1234567890123456789012345678901234567890", + chain="base", + tier="contributor", + github_token="ghp_anytoken", + skip_antisybil=True, + ) + + self.assertFalse(success) + self.assertIsNone(claim) + self.assertIn("does not match", message) + + +if __name__ == "__main__": + unittest.main()