Skip to content

Commit 9b7a7d1

Browse files
authored
fix: require Ed25519 signature in governance/propose to prevent wallet impersonation (#6530)
* fix: require Ed25519 signature in governance/propose to prevent wallet impersonation POST /governance/propose accepted any wallet address as the proposer with no signature verification. Any attacker who knew a wallet address holding > 10 RTC could submit governance proposals attributed to that wallet, permanently polluting the proposals table with records the victim never created. The sister endpoint /governance/vote already required a valid Ed25519 signature, a nonce, and a public_key that derives to the voter wallet. /governance/propose now enforces the same checks: 1. Caller must supply nonce, signature, public_key. 2. address_from_pubkey(public_key) must equal the supplied wallet. 3. verify_rtc_signature() must pass over json.dumps({description, nonce, title, wallet}, sort_keys=True). Adds test_governance_propose_missing_sig_poc.py (4 tests) demonstrating the impersonation attack and the corrected authentication flow. * test: replace stubs with route-level Flask tests using real nacl signatures Address reviewer feedback on PR #6530: - Tests now use the actual Flask test client against a real governance_propose() handler (not stub functions) - Real nacl.signing keypairs sign the exact canonical message json.dumps({description, nonce, title, wallet}, sort_keys=True) so the API contract is locked by the test suite - Covers all 4 requested scenarios: missing auth fields (400), wallet/pubkey mismatch (400), wrong-key signature (401), valid signed proposal (201) - Also covers non-string type guards, garbage sig hex, balance guard, and DB persistence (11 tests total, all passing) * test: rewrite to use real app module and fix Windows SQLite tearDown The previous test built a local mini-Flask app that mirrored the route logic. This was independent of the real governance_propose() implementation and could pass or fail without catching regressions in the actual handler. Changes: - Load the real app via importlib (setting RUSTCHAIN_DB_PATH before exec_module so DB_PATH is initialised to a temp file from the start) - Each test gets its own mkstemp DB; setUp patches _mod.DB_PATH, tearDown restores it and calls gc.collect() before os.unlink() to release any open sqlite3 connections before file deletion (fixes Windows PermissionError on tearDown) - Seed uses the real balances schema: miner_id / amount_i64 (micro-units) - All 12 tests exercise the actual /governance/propose route through app.test_client() All 12 tests pass.
1 parent 0d4694a commit 9b7a7d1

2 files changed

Lines changed: 323 additions & 0 deletions

File tree

node/rustchain_v2_integrated_v2.2.1_rip200.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6106,10 +6106,49 @@ def governance_propose():
61066106
proposer_wallet = str(data.get('wallet', '')).strip()
61076107
title = str(data.get('title', '')).strip()
61086108
description = str(data.get('description', '')).strip()
6109+
nonce = str(data.get('nonce', '')).strip()
6110+
6111+
# SECURITY: Validate signature/public_key field types before coercion
6112+
_raw_sig = data.get('signature')
6113+
_raw_pubkey = data.get('public_key')
6114+
if _raw_sig is not None and not isinstance(_raw_sig, str):
6115+
return jsonify({"ok": False, "error": "INVALID_SIGNATURE_TYPE",
6116+
"message": "Field 'signature' must be a string"}), 400
6117+
if _raw_pubkey is not None and not isinstance(_raw_pubkey, str):
6118+
return jsonify({"ok": False, "error": "INVALID_PUBLIC_KEY_TYPE",
6119+
"message": "Field 'public_key' must be a string"}), 400
6120+
signature = str(_raw_sig or '').strip()
6121+
public_key = str(_raw_pubkey or '').strip()
61096122

61106123
if not proposer_wallet or not title or not description:
61116124
return jsonify({"ok": False, "error": "wallet, title and description are required"}), 400
61126125

6126+
if not all([nonce, signature, public_key]):
6127+
return jsonify({
6128+
"ok": False,
6129+
"error": "nonce, signature, public_key are required to authenticate the proposer",
6130+
}), 400
6131+
6132+
# Verify the proposer controls the wallet they are proposing from
6133+
try:
6134+
expected_wallet = address_from_pubkey(public_key)
6135+
except (ValueError, TypeError):
6136+
return jsonify({"ok": False, "error": "invalid_public_key",
6137+
"message": "public_key is not valid hex"}), 400
6138+
if proposer_wallet != expected_wallet:
6139+
return jsonify({"ok": False, "error": "wallet_does_not_match_public_key",
6140+
"expected": expected_wallet, "got": proposer_wallet}), 400
6141+
6142+
propose_message = json.dumps({
6143+
"description": description,
6144+
"nonce": nonce,
6145+
"title": title,
6146+
"wallet": proposer_wallet,
6147+
}, sort_keys=True, separators=(",", ":")).encode()
6148+
6149+
if not verify_rtc_signature(public_key, propose_message, signature):
6150+
return jsonify({"ok": False, "error": "invalid_signature"}), 401
6151+
61136152
with sqlite3.connect(DB_PATH) as conn:
61146153
conn.row_factory = sqlite3.Row
61156154
c = conn.cursor()
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
# SPDX-License-Identifier: MIT
2+
"""
3+
Route-level PoC: POST /governance/propose requires Ed25519 wallet ownership proof.
4+
5+
Tests the real governance_propose() handler in rustchain_v2_integrated_v2.2.1_rip200.py
6+
using a real Flask test client, a temp SQLite database, and real nacl-signed payloads.
7+
8+
Covers:
9+
1. Missing auth fields -> 400
10+
2. wallet/public-key mismatch -> 400
11+
3. Invalid (wrong-key) signature -> 401
12+
4. Valid signed proposal -> 201 (happy path + API contract fixture)
13+
5. Balance guard still applies -> 403
14+
6. Non-string field type guards -> 400
15+
"""
16+
17+
import gc
18+
import hashlib
19+
import importlib.util as _ilu
20+
import json
21+
import os
22+
import sqlite3
23+
import sys
24+
import tempfile
25+
import unittest
26+
27+
import pytest
28+
29+
sys.path.insert(0, os.path.dirname(__file__))
30+
31+
try:
32+
from nacl.signing import SigningKey, VerifyKey # noqa: F401
33+
from nacl.exceptions import BadSignatureError # noqa: F401
34+
_HAVE_NACL = True
35+
except ImportError:
36+
_HAVE_NACL = False
37+
38+
pytestmark = pytest.mark.skipif(not _HAVE_NACL, reason="pynacl not installed")
39+
40+
# ---------------------------------------------------------------------------
41+
# Load the real app once at module level.
42+
# Set RUSTCHAIN_DB_PATH before loading so DB_PATH is initialised to our file.
43+
# ---------------------------------------------------------------------------
44+
45+
_module_db_fd, _MODULE_DB = tempfile.mkstemp(suffix=".db")
46+
os.close(_module_db_fd)
47+
48+
os.environ.setdefault("RC_ADMIN_KEY", "0123456789abcdef0123456789abcdef")
49+
os.environ["RUSTCHAIN_DB_PATH"] = _MODULE_DB
50+
51+
_spec = _ilu.spec_from_file_location(
52+
"rustchain_node",
53+
os.path.join(os.path.dirname(__file__), "rustchain_v2_integrated_v2.2.1_rip200.py"),
54+
)
55+
_mod = _ilu.module_from_spec(_spec)
56+
_spec.loader.exec_module(_mod)
57+
_app = _mod.app
58+
59+
60+
# ---------------------------------------------------------------------------
61+
# Crypto helpers
62+
# ---------------------------------------------------------------------------
63+
64+
def _address_from_pubkey(public_key_hex: str) -> str:
65+
return "RTC" + hashlib.sha256(bytes.fromhex(public_key_hex)).hexdigest()[:40]
66+
67+
68+
def _make_keypair() -> tuple[str, str, str]:
69+
sk = SigningKey.generate()
70+
pub = sk.verify_key.encode().hex()
71+
return sk.encode().hex(), pub, _address_from_pubkey(pub)
72+
73+
74+
def _sign_propose(sk_hex: str, wallet: str, title: str, description: str, nonce: str) -> str:
75+
msg = json.dumps({
76+
"description": description,
77+
"nonce": nonce,
78+
"title": title,
79+
"wallet": wallet,
80+
}, sort_keys=True, separators=(",", ":")).encode()
81+
sk = SigningKey(bytes.fromhex(sk_hex))
82+
return sk.sign(msg).signature.hex()
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# DB seeding — uses the schema expected by _balance_i64_for_wallet()
87+
# ---------------------------------------------------------------------------
88+
89+
_BALANCE_ABOVE_MIN = int(100 * 1_000_000) # 100 RTC in micro-units
90+
_BALANCE_BELOW_MIN = int(5 * 1_000_000) # 5 RTC — below 10 RTC minimum
91+
92+
93+
def _seed_db(db_path: str, wallet: str, amount_i64: int) -> None:
94+
conn = sqlite3.connect(db_path)
95+
try:
96+
conn.executescript("""
97+
CREATE TABLE IF NOT EXISTS balances (
98+
miner_id TEXT PRIMARY KEY,
99+
amount_i64 INTEGER NOT NULL DEFAULT 0
100+
);
101+
""")
102+
conn.execute(
103+
"INSERT OR REPLACE INTO balances (miner_id, amount_i64) VALUES (?, ?)",
104+
(wallet, amount_i64),
105+
)
106+
conn.commit()
107+
finally:
108+
conn.close()
109+
110+
111+
# ---------------------------------------------------------------------------
112+
# Test class
113+
# ---------------------------------------------------------------------------
114+
115+
class TestGovernanceProposeRealRoute(unittest.TestCase):
116+
117+
def setUp(self):
118+
fd, self._db_path = tempfile.mkstemp(suffix=".db")
119+
os.close(fd)
120+
self._sk, self._pk, self._wallet = _make_keypair()
121+
_seed_db(self._db_path, self._wallet, _BALANCE_ABOVE_MIN)
122+
_mod.DB_PATH = self._db_path
123+
_app.config["TESTING"] = True
124+
self.client = _app.test_client()
125+
126+
def tearDown(self):
127+
_mod.DB_PATH = _MODULE_DB # restore so other tests are not affected
128+
gc.collect() # release any sqlite3.Connection objects held by the route
129+
try:
130+
os.unlink(self._db_path)
131+
except PermissionError:
132+
pass # Windows: OS will clean up on process exit
133+
134+
# -- helpers ---------------------------------------------------------------
135+
136+
def _post(self, payload: dict):
137+
return self.client.post(
138+
"/governance/propose",
139+
data=json.dumps(payload),
140+
content_type="application/json",
141+
)
142+
143+
def _valid_payload(self, nonce: str = "test-nonce-1") -> dict:
144+
sig = _sign_propose(self._sk, self._wallet,
145+
"Test Proposal", "A real description.", nonce)
146+
return {
147+
"wallet": self._wallet,
148+
"title": "Test Proposal",
149+
"description": "A real description.",
150+
"nonce": nonce,
151+
"signature": sig,
152+
"public_key": self._pk,
153+
}
154+
155+
# -- 1. Missing auth fields ------------------------------------------------
156+
157+
def test_missing_nonce_returns_400(self):
158+
p = self._valid_payload()
159+
del p["nonce"]
160+
r = self._post(p)
161+
self.assertEqual(r.status_code, 400)
162+
body = r.get_json()
163+
self.assertFalse(body["ok"])
164+
self.assertIn("nonce", body["error"])
165+
166+
def test_missing_signature_returns_400(self):
167+
p = self._valid_payload()
168+
del p["signature"]
169+
r = self._post(p)
170+
self.assertEqual(r.status_code, 400)
171+
self.assertFalse(r.get_json()["ok"])
172+
173+
def test_missing_public_key_returns_400(self):
174+
p = self._valid_payload()
175+
del p["public_key"]
176+
r = self._post(p)
177+
self.assertEqual(r.status_code, 400)
178+
self.assertFalse(r.get_json()["ok"])
179+
180+
def test_missing_wallet_returns_400(self):
181+
p = self._valid_payload()
182+
del p["wallet"]
183+
r = self._post(p)
184+
self.assertEqual(r.status_code, 400)
185+
self.assertFalse(r.get_json()["ok"])
186+
187+
# -- 2. Wallet/public-key mismatch -----------------------------------------
188+
189+
def test_wallet_pubkey_mismatch_returns_400(self):
190+
_, other_pk, _ = _make_keypair()
191+
p = self._valid_payload()
192+
p["public_key"] = other_pk
193+
r = self._post(p)
194+
self.assertEqual(r.status_code, 400)
195+
body = r.get_json()
196+
self.assertFalse(body["ok"])
197+
self.assertEqual(body["error"], "wallet_does_not_match_public_key")
198+
199+
# -- 3. Invalid signature --------------------------------------------------
200+
201+
def test_wrong_key_signature_returns_401(self):
202+
other_sk, other_pk, other_wallet = _make_keypair()
203+
_seed_db(self._db_path, other_wallet, _BALANCE_ABOVE_MIN)
204+
nonce = "tampered-nonce"
205+
sig = _sign_propose(other_sk, other_wallet, "Legit Title", "Legit desc.", nonce)
206+
r = self._post({
207+
"wallet": other_wallet,
208+
"title": "DIFFERENT TITLE",
209+
"description": "Legit desc.",
210+
"nonce": nonce,
211+
"signature": sig,
212+
"public_key": other_pk,
213+
})
214+
self.assertEqual(r.status_code, 401)
215+
body = r.get_json()
216+
self.assertFalse(body["ok"])
217+
self.assertEqual(body["error"], "invalid_signature")
218+
219+
def test_garbage_signature_hex_returns_401(self):
220+
p = self._valid_payload()
221+
p["signature"] = "deadbeef" * 16
222+
r = self._post(p)
223+
self.assertEqual(r.status_code, 401)
224+
self.assertFalse(r.get_json()["ok"])
225+
226+
# -- 4. Valid signed proposal (happy path + API contract) ------------------
227+
228+
def test_valid_signed_proposal_returns_201(self):
229+
r = self._post(self._valid_payload("unique-nonce-42"))
230+
self.assertEqual(r.status_code, 201)
231+
body = r.get_json()
232+
self.assertTrue(body["ok"])
233+
prop = body["proposal"]
234+
self.assertEqual(prop["wallet"], self._wallet)
235+
self.assertEqual(prop["title"], "Test Proposal")
236+
self.assertEqual(prop["description"], "A real description.")
237+
self.assertEqual(prop["status"], "active")
238+
239+
def test_valid_proposal_persisted_in_db(self):
240+
self._post(self._valid_payload("nonce-db-check"))
241+
conn = sqlite3.connect(self._db_path)
242+
try:
243+
rows = conn.execute("SELECT proposer_wallet FROM governance_proposals").fetchall()
244+
finally:
245+
conn.close()
246+
self.assertEqual(len(rows), 1)
247+
self.assertEqual(rows[0][0], self._wallet)
248+
249+
# -- 5. Balance guard still applies ----------------------------------------
250+
251+
def test_insufficient_balance_returns_403(self):
252+
poor_sk, poor_pk, poor_wallet = _make_keypair()
253+
_seed_db(self._db_path, poor_wallet, _BALANCE_BELOW_MIN)
254+
nonce = "poor-nonce"
255+
sig = _sign_propose(poor_sk, poor_wallet, "Cheap Proposal", "desc.", nonce)
256+
r = self._post({
257+
"wallet": poor_wallet, "title": "Cheap Proposal",
258+
"description": "desc.", "nonce": nonce,
259+
"signature": sig, "public_key": poor_pk,
260+
})
261+
self.assertEqual(r.status_code, 403)
262+
body = r.get_json()
263+
self.assertFalse(body["ok"])
264+
self.assertEqual(body["error"], "insufficient_balance_to_propose")
265+
266+
# -- 6. Non-string type guards ---------------------------------------------
267+
268+
def test_non_string_signature_type_returns_400(self):
269+
p = self._valid_payload()
270+
p["signature"] = 12345
271+
r = self._post(p)
272+
self.assertEqual(r.status_code, 400)
273+
self.assertIn("INVALID_SIGNATURE_TYPE", r.get_json()["error"])
274+
275+
def test_non_string_pubkey_type_returns_400(self):
276+
p = self._valid_payload()
277+
p["public_key"] = ["not", "a", "string"]
278+
r = self._post(p)
279+
self.assertEqual(r.status_code, 400)
280+
self.assertIn("INVALID_PUBLIC_KEY_TYPE", r.get_json()["error"])
281+
282+
283+
if __name__ == "__main__":
284+
unittest.main()

0 commit comments

Comments
 (0)