Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions node/rustchain_v2_integrated_v2.2.1_rip200.py
Original file line number Diff line number Diff line change
Expand Up @@ -2342,6 +2342,40 @@ def resolve_enroll_fingerprint(conn, miner_pk: str, data: dict) -> dict:
return {}


def resolve_enroll_weight_device(conn, miner_pk: str, data: dict) -> tuple:
"""Pick the (family, arch) used for the HARDWARE_WEIGHTS reward multiplier.

SECURITY: the reward weight MUST be bound to the hardware the miner
actually attested and fingerprinted, not to whatever ``device`` the
enrollment request body claims. The enrollment signature covers only
``miner_pubkey|miner_id|epoch`` (see enroll_epoch), so ``device`` is
unsigned and fully attacker-controlled. Trusting it lets a miner that
attested on ordinary x86 (``x86``/``modern`` -> 0.8x) enroll claiming e.g.
ARM ``arm2`` (4.0x) — or ``console``/vintage tiers — and collect a ~5x
inflated share of the fixed per-epoch reward pot, draining honest miners.

Prefer the verified device stored at attestation
(``miner_attest_recent.device_family`` / ``device_arch``, written from
``derive_verified_device`` against the submitted fingerprint). Fall back to
the request body only when no stored verified device exists (legacy /
pre-migration rows), mirroring the resolve_enroll_fingerprint fallback so
deployed miners are not regressed. Robust to a missing row/column.
"""
try:
row = conn.execute(
"SELECT device_family, device_arch FROM miner_attest_recent WHERE miner = ?",
(miner_pk,),
).fetchone()
if row and row[0]:
return str(row[0]), str(row[1] or "default")
except sqlite3.Error:
pass
device = data.get("device")
if not isinstance(device, dict):
device = {}
return str(device.get("family", "x86")), str(device.get("arch", "default"))


def _claimed_family_and_arch(device: dict) -> tuple:
"""
Extract the claimed device family and architecture from a device dict.
Expand Down Expand Up @@ -5034,7 +5068,6 @@ def enroll_epoch():
client_ip = get_client_ip()
miner_pk = data.get('miner_pubkey')
miner_id = data.get('miner_id', miner_pk) # Use miner_id if provided
device = data.get('device', {})

if not miner_pk:
return jsonify({"error": "Missing miner_pubkey"}), 400
Expand Down Expand Up @@ -5183,15 +5216,21 @@ def enroll_epoch():
ENROLL_REJ[reason] = ENROLL_REJ.get(reason, 0) + 1
return jsonify(check_result), 412

# Calculate weight based on hardware
family = device.get('family', 'x86')
arch = device.get('arch', 'default')
hw_weight = HARDWARE_WEIGHTS.get(family, {}).get(arch, 1.0)

# RIP-PoA Phase 2: failed fingerprints are tracked but receive zero rewards.
fingerprint_failed = check_result.get('fingerprint_failed', False)

with sqlite3.connect(DB_PATH) as c:
# Calculate weight based on hardware.
# SECURITY: derive the reward multiplier from the hardware the miner
# actually attested + fingerprinted (miner_attest_recent), NOT the
# caller-supplied `device` in the request body. The enrollment
# signature only covers (miner_pubkey|miner_id|epoch), so trusting body
# `device` let a miner that attested on ordinary x86 (0.8x) enroll
# claiming e.g. ARM 'arm2' (4.0x) and collect a ~5x inflated share of
# the fixed epoch reward pot.
family, arch = resolve_enroll_weight_device(c, miner_pk, data)
hw_weight = HARDWARE_WEIGHTS.get(family, {}).get(arch, 1.0)

rotation_eval = evaluate_rotating_fingerprint_checks(
c,
epoch,
Expand Down
281 changes: 281 additions & 0 deletions node/tests/test_enroll_weight_device_spoof.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
# SPDX-License-Identifier: MIT
"""
Regression test for reward-weight spoofing via the unsigned `device` field on
/epoch/enroll.

Vulnerability (pre-fix): enroll_epoch computed the HARDWARE_WEIGHTS reward
multiplier from the caller-supplied request-body `device` (family/arch). The
enrollment signature only covers (miner_pubkey|miner_id|epoch), so `device` is
unsigned and attacker-controlled. A miner that attests on ordinary x86
(verified device x86/modern -> 0.8x) could enroll claiming e.g. ARM `arm2`
(4.0x) and be recorded with a ~5x inflated epoch weight, draining the fixed
per-epoch reward pot from honest participants.

Fix: the weight is bound to the *verified* device the node stored at
attestation (miner_attest_recent.device_family / device_arch, written from
derive_verified_device) via resolve_enroll_weight_device(). The request-body
`device` is only consulted as a legacy fallback when no verified device exists.

These tests drive the real /epoch/enroll endpoint with a valid Ed25519
signature (reusing the harness style of test_enroll_signature_verification).
They FAIL on the pre-fix code (stored hw_weight == 4.0) and PASS on the fix
(stored hw_weight == 0.8). A legacy-fallback test guards against regressing
miners whose stored verified device is absent.
"""

import importlib.util
import gc
import os
import sqlite3
import sys
import tempfile
import time
import unittest
from pathlib import Path

try:
import nacl.signing
HAVE_NACL = True
except Exception:
HAVE_NACL = False

NODE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
MODULE_PATH = os.path.join(NODE_DIR, "rustchain_v2_integrated_v2.2.1_rip200.py")

EXTRA_SCHEMA = [
"CREATE TABLE IF NOT EXISTS blocked_wallets (wallet TEXT PRIMARY KEY, reason TEXT)",
"CREATE TABLE IF NOT EXISTS ip_rate_limit (client_ip TEXT NOT NULL, miner_id TEXT NOT NULL, ts INTEGER NOT NULL, PRIMARY KEY (client_ip, miner_id))",
"CREATE TABLE IF NOT EXISTS miner_attest_recent (miner TEXT PRIMARY KEY, ts_ok INTEGER NOT NULL, device_family TEXT, device_arch TEXT, entropy_score REAL DEFAULT 0, fingerprint_passed INTEGER DEFAULT 0, source_ip TEXT, warthog_bonus REAL DEFAULT 1.0, signing_pubkey TEXT)",
"CREATE TABLE IF NOT EXISTS hardware_bindings (hardware_id TEXT PRIMARY KEY, bound_miner TEXT NOT NULL, device_arch TEXT, device_model TEXT, bound_at INTEGER NOT NULL, attestation_count INTEGER DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS miner_header_keys (miner_id TEXT PRIMARY KEY, pubkey_hex TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS miner_macs (miner TEXT NOT NULL, mac_hash TEXT NOT NULL, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, count INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (miner, mac_hash))",
"CREATE TABLE IF NOT EXISTS epoch_enroll (epoch INTEGER, miner_pk TEXT, weight REAL, PRIMARY KEY (epoch, miner_pk))",
"CREATE TABLE IF NOT EXISTS balances (miner_pk TEXT PRIMARY KEY, balance_rtc REAL NOT NULL DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS tickets (ticket_id TEXT PRIMARY KEY, expires_at INTEGER, commitment TEXT)",
"CREATE TABLE IF NOT EXISTS epoch_state (epoch INTEGER PRIMARY KEY, pot REAL, finalized INTEGER DEFAULT 0)",
]


def _sign_message(miner_id, wallet, nonce, commitment):
signing_key = nacl.signing.SigningKey.generate()
pubkey_hex = signing_key.verify_key.encode().hex()
message = '{}|{}|{}|{}'.format(miner_id, wallet, nonce, commitment)
signature = signing_key.sign(message.encode('utf-8'))
return signature.signature.hex(), pubkey_hex, signing_key


def _sign_enrollment(miner_pk, miner_id, epoch, signing_key):
pubkey_hex = signing_key.verify_key.encode().hex()
message = '{}|{}|{}'.format(miner_pk, miner_id, epoch)
signature = signing_key.sign(message.encode('utf-8'))
return signature.signature.hex(), pubkey_hex


@unittest.skipUnless(HAVE_NACL, "pynacl not installed")
class TestEnrollWeightDeviceSpoof(unittest.TestCase):
HONEST_FAMILY = "x86"
HONEST_ARCH = "modern" # HARDWARE_WEIGHTS["x86"]["modern"] == 0.8
SPOOF_FAMILY = "ARM"
SPOOF_ARCH = "arm2" # HARDWARE_WEIGHTS["ARM"]["arm2"] == 4.0

@classmethod
def setUpClass(cls):
cls._tmp = tempfile.TemporaryDirectory()
cls._prev = {k: os.environ.get(k) for k in
("RC_ADMIN_KEY", "RUSTCHAIN_DB_PATH", "RUSTCHAIN_DISABLE_P2P_AUTO_START")}
cls._loaded = []
os.environ["RC_ADMIN_KEY"] = "0123456789abcdef0123456789abcdef"
os.environ["RUSTCHAIN_DISABLE_P2P_AUTO_START"] = "1"
if NODE_DIR not in sys.path:
sys.path.insert(0, NODE_DIR)

@classmethod
def tearDownClass(cls):
for k, v in cls._prev.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
cls._release()
for attempt in range(5):
try:
cls._tmp.cleanup()
break
except PermissionError:
if attempt == 4:
raise
gc.collect()
time.sleep(0.2)

@classmethod
def _release(cls):
try:
from prometheus_client import REGISTRY
except Exception:
cls._loaded = []
return
for mod in cls._loaded:
for metric_name in ("withdrawal_requests", "withdrawal_completed",
"withdrawal_failed", "balance_gauge", "epoch_gauge",
"withdrawal_queue_size"):
metric = getattr(mod, metric_name, None)
if metric is None:
continue
try:
REGISTRY.unregister(metric)
except (KeyError, ValueError):
pass
cls._loaded = []
gc.collect()

def tearDown(self):
self._release()

def _load(self, module_name, db_name):
self._release()
db_path = str(Path(self._tmp.name) / db_name)
os.environ["RUSTCHAIN_DB_PATH"] = db_path
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
self._loaded.append(mod)
mod.HAVE_REPLAY_DEFENSE = False
for attempt in range(5):
try:
mod.init_db()
break
except sqlite3.OperationalError as exc:
if "locked" not in str(exc).lower() or attempt == 4:
raise
time.sleep(0.2)
with sqlite3.connect(db_path) as conn:
for stmt in EXTRA_SCHEMA:
conn.execute(stmt)
conn.commit()
return mod, db_path

def _enroll(self, mod, payload):
with mod.app.test_request_context("/epoch/enroll", method="POST", json=payload):
resp = mod.enroll_epoch()
if isinstance(resp, tuple):
body, status = resp
return status, body.get_json()
return resp.status_code, resp.get_json()

def _attest(self, mod, miner, miner_id):
with mod.app.test_request_context("/attest/challenge", method="POST", json={}):
nonce = mod.get_challenge().get_json()["nonce"]
commitment = "deadbeef"
sig_hex, pubkey_hex, signing_key = _sign_message(miner_id, miner, nonce, commitment)
payload = {
"miner": miner,
"miner_id": miner_id,
"report": {"nonce": nonce, "commitment": commitment},
"device": {"family": "x86_64", "arch": "default", "model": "test-box", "cores": 4},
"signals": {"hostname": "test-host", "macs": []},
"fingerprint": {"all_passed": True, "checks": {"clock_drift": {"passed": True}}},
"signature": sig_hex,
"public_key": pubkey_hex,
}
with mod.app.test_request_context("/attest/submit", method="POST", json=payload):
status, body = self._resp(mod._submit_attestation_impl())
self.assertEqual(status, 200, f"attestation failed: {body}")
return signing_key

@staticmethod
def _resp(resp):
if isinstance(resp, tuple):
body, status = resp
return status, body.get_json()
return resp.status_code, resp.get_json()

def _set_verified_device(self, db_path, miner, family, arch):
# Pin fingerprint_passed=1 and a recent MAC so enrollment takes the real
# reward path (a failed fingerprint / missing MAC would zero or gate the
# weight and mask the multiplier-selection behavior this test targets).
now = int(time.time())
with sqlite3.connect(db_path) as conn:
conn.execute(
"UPDATE miner_attest_recent "
"SET device_family=?, device_arch=?, fingerprint_passed=1 WHERE miner=?",
(family, arch, miner),
)
conn.execute(
"INSERT OR REPLACE INTO miner_macs "
"(miner, mac_hash, first_ts, last_ts, count) VALUES (?, ?, ?, ?, 1)",
(miner, "aa" * 6, now, now),
)
conn.commit()

def _current_epoch(self, mod):
with mod.app.test_request_context("/epoch", method="GET"):
return mod.get_epoch().get_json()["epoch"]

def test_spoofed_device_cannot_inflate_weight(self):
"""Enroll body claiming a high-multiplier arch must NOT raise the reward
weight above the miner's attested (verified) hardware tier."""
mod, db_path = self._load("rc_enroll_spoof", "enroll_spoof.db")

honest_w = mod.HARDWARE_WEIGHTS[self.HONEST_FAMILY][self.HONEST_ARCH]
spoof_w = mod.HARDWARE_WEIGHTS[self.SPOOF_FAMILY][self.SPOOF_ARCH]
self.assertLess(honest_w, spoof_w, "test fixture must span a real weight gap")

miner = "RTC_SPOOFER"
miner_id = "miner_spoof_1"
signing_key = self._attest(mod, miner, miner_id)
# Pin the node's *verified* view of this miner's hardware to honest x86.
self._set_verified_device(db_path, miner, self.HONEST_FAMILY, self.HONEST_ARCH)

epoch = self._current_epoch(mod)
sig_hex, enroll_pubkey = _sign_enrollment(miner, miner_id, epoch, signing_key)
status, body = self._enroll(mod, {
"miner_pubkey": miner,
"miner_id": miner_id,
# Attacker claims a 4.0x arch it never attested to.
"device": {"family": self.SPOOF_FAMILY, "arch": self.SPOOF_ARCH},
"signature": sig_hex,
"public_key": enroll_pubkey,
})

self.assertEqual(status, 200, body)
self.assertTrue(body["ok"], body)
# The reward multiplier is bound to the attested hardware, not the body.
self.assertEqual(body["hw_weight"], honest_w,
f"weight should follow verified {self.HONEST_ARCH}={honest_w}")
self.assertNotEqual(body["hw_weight"], spoof_w,
"spoofed body device must not set the reward weight")
# The honest multiplier produces a positive epoch weight (real reward
# path, not the failed-fingerprint zero-weight branch). weight_units =
# epoch_weight_to_units(hw_weight * active_ratio), so hw_weight is what
# scales the miner's share of the fixed epoch pot.
self.assertGreater(body["weight"], 0.0, "honest miner should have positive weight")

def test_legacy_miner_without_verified_device_falls_back_to_body(self):
"""A pre-migration row with no stored verified device must still honor the
body device, so deployed legacy miners are not regressed to weight 1.0."""
mod, db_path = self._load("rc_enroll_legacy", "enroll_legacy.db")

miner = "RTC_LEGACY"
miner_id = "miner_legacy_1"
signing_key = self._attest(mod, miner, miner_id)
# Simulate a legacy/pre-migration attestation row: no verified device.
self._set_verified_device(db_path, miner, None, None)

epoch = self._current_epoch(mod)
sig_hex, enroll_pubkey = _sign_enrollment(miner, miner_id, epoch, signing_key)
body_w = mod.HARDWARE_WEIGHTS[self.SPOOF_FAMILY][self.SPOOF_ARCH]
status, body = self._enroll(mod, {
"miner_pubkey": miner,
"miner_id": miner_id,
"device": {"family": self.SPOOF_FAMILY, "arch": self.SPOOF_ARCH},
"signature": sig_hex,
"public_key": enroll_pubkey,
})

self.assertEqual(status, 200, body)
self.assertEqual(body["hw_weight"], body_w,
"legacy row with no verified device should use body device")


if __name__ == "__main__":
unittest.main(verbosity=2)
Loading