Skip to content
Merged
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
8 changes: 4 additions & 4 deletions miners/pico_bridge/pico_bridge_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,10 +565,10 @@ def run_attestation_cycle(self) -> bool:
print(f"[INFO] Fetching challenge from {node_url}...")
nonce = fetch_challenge(node_url, miner_name)
if not nonce:
nonce = hashlib.sha256(f"{miner_name}_{time.time()}".encode()).hexdigest()
print(f"[WARN] Using locally generated nonce: {nonce[:16]}...")
else:
print(f"[INFO] Received nonce: {nonce[:16]}...")
print("[ERROR] Attestation challenge unavailable; refusing insecure local nonce fallback.")
return False

print(f"[INFO] Received nonce: {nonce[:16]}...")

# Step 2: Send challenge to Pico/console
print("[INFO] Sending challenge to Pico bridge...")
Expand Down
56 changes: 56 additions & 0 deletions miners/pico_bridge/tests/test_pico_bridge_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@

from pico_bridge_miner import (
PicoSimulator,
PicoBridgeMiner,
build_attestation_payload,
CONSOLE_PROFILES,
)
import pico_bridge_miner


def test_pico_simulator_connection():
Expand Down Expand Up @@ -235,6 +237,59 @@ def test_console_profiles_complete():
print("✓ test_console_profiles_complete passed")


def test_attestation_cycle_fails_closed_without_challenge():
"""Test miner refuses insecure local nonce fallback when challenge fetch fails."""

class StubBridge:
def __init__(self):
self.send_called = False
self.read_called = False

def send_challenge(self, nonce):
self.send_called = True
return True

def read_attestation(self, timeout_sec=30.0):
self.read_called = True
return {"board_id": "should-not-be-read"}

miner = PicoBridgeMiner(
{
"console_type": "n64_mips",
"simulation_mode": True,
"node_url": "https://example.invalid",
"miner_name": "test-pico",
"wallet_id": "RTCtest123",
}
)
bridge = StubBridge()
miner.bridge = bridge

original_fetch = pico_bridge_miner.fetch_challenge
original_submit = pico_bridge_miner.submit_attestation
submit_called = {"value": False}

try:
pico_bridge_miner.fetch_challenge = lambda node_url, miner_name: None

def _unexpected_submit(node_url, payload):
submit_called["value"] = True
return True, "unexpected"

pico_bridge_miner.submit_attestation = _unexpected_submit

result = miner.run_attestation_cycle()

assert result is False
assert not bridge.send_called
assert not bridge.read_called
assert not submit_called["value"]
print("✓ test_attestation_cycle_fails_closed_without_challenge passed")
finally:
pico_bridge_miner.fetch_challenge = original_fetch
pico_bridge_miner.submit_attestation = original_submit


def run_all_tests():
"""Run all Pico bridge miner tests."""
print("=" * 60)
Expand All @@ -251,6 +306,7 @@ def run_all_tests():
test_build_attestation_payload_checks,
test_build_attestation_payload_emulation_detection,
test_console_profiles_complete,
test_attestation_cycle_fails_closed_without_challenge,
]

passed = 0
Expand Down
56 changes: 11 additions & 45 deletions node/rustchain_v2_integrated_v2.2.1_rip200.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,6 @@ def _normalize_attestation_report(report):
return normalized


ATTEST_NONCE_SKEW_SECONDS = int(os.getenv("ATTEST_NONCE_SKEW_SECONDS", "60"))
_ATTEST_CHALLENGE_NONCE_RE = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE)


def attest_ensure_tables(conn):
"""Create the attestation nonce tables expected by replay protection."""
conn.execute("CREATE TABLE IF NOT EXISTS nonces (nonce TEXT PRIMARY KEY, expires_at INTEGER)")
Expand All @@ -373,29 +369,8 @@ def attest_cleanup_expired(conn, now_ts: Optional[int] = None):
conn.commit()


def extract_attestation_timestamp(data: dict, report: dict, nonce: Optional[str]) -> Optional[int]:
"""Extract an optional attestation timestamp from request payload fields."""
for source in (report or {}, data or {}):
for field_name in ("nonce_ts", "nonce_timestamp", "timestamp", "server_time"):
raw_value = source.get(field_name)
if isinstance(raw_value, bool):
continue
if isinstance(raw_value, (int, float)):
if math.isfinite(raw_value):
return int(raw_value)
continue
if isinstance(raw_value, str) and raw_value.strip().isdigit():
return int(raw_value.strip())
return None


def _attest_nonce_requires_challenge(nonce: str, nonce_ts: Optional[int]) -> bool:
"""Current challenge endpoint emits 64-hex nonces with no embedded timestamp."""
return nonce_ts is None and bool(_ATTEST_CHALLENGE_NONCE_RE.fullmatch(nonce))


def attest_validate_challenge(conn, nonce: str, now_ts: Optional[int] = None):
"""Validate and consume a one-time challenge nonce."""
"""Validate and consume a one-time challenge nonce from the active node store."""
now_ts = int(time.time()) if now_ts is None else int(now_ts)
attest_cleanup_expired(conn, now_ts=now_ts)
row = conn.execute(
Expand All @@ -421,10 +396,8 @@ def attest_validate_and_store_nonce(
miner: str,
nonce: str,
now_ts: Optional[int] = None,
nonce_ts: Optional[int] = None,
skew_seconds: int = ATTEST_NONCE_SKEW_SECONDS,
):
"""Reject replayed or stale attestation nonces and persist accepted ones."""
"""Require a live server-issued challenge and persist accepted attestation nonces."""
now_ts = int(time.time()) if now_ts is None else int(now_ts)
nonce = _attest_text(nonce)
miner = _attest_valid_miner(miner) or _attest_text(miner) or ""
Expand All @@ -439,15 +412,11 @@ def attest_validate_and_store_nonce(
if replay_row:
return False, "nonce_replay", None

challenge_expires_at = None
if _attest_nonce_requires_challenge(nonce, nonce_ts):
ok, err, challenge_expires_at = attest_validate_challenge(conn, nonce, now_ts=now_ts)
if not ok:
return False, err, None
elif nonce_ts is not None and abs(int(nonce_ts) - now_ts) > max(int(skew_seconds), 0):
return False, "nonce_stale", None
ok, err, challenge_expires_at = attest_validate_challenge(conn, nonce, now_ts=now_ts)
if not ok:
return False, err, None

expires_at = challenge_expires_at or (now_ts + max(int(skew_seconds), 1))
expires_at = int(challenge_expires_at)
conn.execute(
"INSERT INTO used_nonces (nonce, miner_id, first_seen, expires_at) VALUES (?, ?, ?, ?)",
(nonce, miner, now_ts, expires_at),
Expand Down Expand Up @@ -2481,7 +2450,11 @@ def miner_dashboard_page():

@app.route('/attest/challenge', methods=['POST'])
def get_challenge():
"""Issue challenge for hardware attestation"""
"""Issue challenge for hardware attestation.

Deployments with multiple attestation backends should keep submit traffic
sticky to the issuing node or share the nonce store across nodes.
"""
nonce = secrets.token_hex(32)
expires = int(time.time()) + 300 # 5 minutes

Expand Down Expand Up @@ -2629,14 +2602,12 @@ def _submit_attestation_impl():
"code": "MISSING_NONCE"
}), 400

nonce_ts = extract_attestation_timestamp(data, report, nonce)
with sqlite3.connect(DB_PATH) as nonce_conn:
nonce_ok, nonce_err, _ = attest_validate_and_store_nonce(
nonce_conn,
miner=miner,
nonce=nonce,
now_ts=int(time.time()),
nonce_ts=nonce_ts,
)
if not nonce_ok:
nonce_messages = {
Expand All @@ -2650,11 +2621,6 @@ def _submit_attestation_impl():
"Attestation nonce has already been used",
"NONCE_REPLAY",
),
"nonce_stale": (
"nonce_stale",
"Attestation nonce timestamp is outside the allowed skew window",
"NONCE_STALE",
),
}
error_name, message, code = nonce_messages.get(
nonce_err,
Expand Down
50 changes: 20 additions & 30 deletions node/tests/test_attest_nonce_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ def _conn(self):

def test_nonce_replay_rejected(self):
with self._conn() as conn:
conn.execute("INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)", ("nonce-1", 1100))
ok, err, _ = self.mod.attest_validate_and_store_nonce(
conn,
miner="miner-1",
nonce="nonce-1",
now_ts=1000,
nonce_ts=1000,
)
self.assertTrue(ok)
self.assertIsNone(err)
Expand All @@ -60,49 +60,43 @@ def test_nonce_replay_rejected(self):
miner="miner-1",
nonce="nonce-1",
now_ts=1001,
nonce_ts=1001,
)
self.assertFalse(ok)
self.assertEqual(err, "nonce_replay")

def test_nonce_freshness_with_skew_window(self):
def test_attestation_requires_server_issued_challenge(self):
with self._conn() as conn:
ok, err, _ = self.mod.attest_validate_and_store_nonce(
conn,
miner="miner-1",
nonce="nonce-stale",
now_ts=1000,
nonce_ts=900,
skew_seconds=60,
)
self.assertFalse(ok)
self.assertEqual(err, "nonce_stale")
self.assertEqual(err, "challenge_invalid")

def test_expired_challenge_is_rejected(self):
with self._conn() as conn:
conn.execute("INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)", ("expired-challenge", 950))
ok, err, _ = self.mod.attest_validate_and_store_nonce(
conn,
miner="miner-1",
nonce="nonce-fresh",
nonce="expired-challenge",
now_ts=1000,
nonce_ts=950,
skew_seconds=60,
)
self.assertTrue(ok)
self.assertIsNone(err)
self.assertFalse(ok)
self.assertEqual(err, "challenge_invalid")

def test_hex_nonce_without_timestamp_is_backward_compatible(self):
def test_challenge_style_nonce_cannot_bypass_with_client_timestamp(self):
with self._conn() as conn:
nonce_ts = self.mod.extract_attestation_timestamp({}, {}, "a7f1c4e9")
self.assertIsNone(nonce_ts)

ok, err, _ = self.mod.attest_validate_and_store_nonce(
conn,
miner="miner-legacy",
nonce="a7f1c4e9",
miner="miner-1",
nonce="b" * 64,
now_ts=1000,
nonce_ts=nonce_ts,
)
self.assertTrue(ok)
self.assertIsNone(err)
self.assertFalse(ok)
self.assertEqual(err, "challenge_invalid")

def test_challenge_is_one_time(self):
with self._conn() as conn:
Expand All @@ -118,21 +112,17 @@ def test_challenge_is_one_time(self):

def test_expired_entries_cleanup(self):
with self._conn() as conn:
conn.execute(
"INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)",
("old-challenge", 950),
)
conn.execute(
"INSERT INTO used_nonces (nonce, miner_id, first_seen, expires_at) VALUES (?, ?, ?, ?)",
("old-nonce", "miner-1", 900, 950),
)
self.mod.attest_cleanup_expired(conn, now_ts=1000)

ok, err, _ = self.mod.attest_validate_and_store_nonce(
conn,
miner="miner-1",
nonce="old-nonce",
now_ts=1000,
nonce_ts=None,
)
self.assertTrue(ok)
self.assertIsNone(err)
self.assertEqual(conn.execute("SELECT COUNT(*) FROM nonces").fetchone()[0], 0)
self.assertEqual(conn.execute("SELECT COUNT(*) FROM used_nonces").fetchone()[0], 0)


if __name__ == "__main__":
Expand Down
49 changes: 49 additions & 0 deletions node/tests/test_attest_submit_challenge_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class TestAttestSubmitChallengeBinding(unittest.TestCase):
def setUpClass(cls):
cls._tmp = tempfile.TemporaryDirectory()
cls._prev_admin_key = os.environ.get("RC_ADMIN_KEY")
cls._prev_db_path = os.environ.get("RUSTCHAIN_DB_PATH")
os.environ["RC_ADMIN_KEY"] = "0123456789abcdef0123456789abcdef"

if NODE_DIR not in sys.path:
Expand All @@ -38,6 +39,10 @@ def tearDownClass(cls):
os.environ.pop("RC_ADMIN_KEY", None)
else:
os.environ["RC_ADMIN_KEY"] = cls._prev_admin_key
if cls._prev_db_path is None:
os.environ.pop("RUSTCHAIN_DB_PATH", None)
else:
os.environ["RUSTCHAIN_DB_PATH"] = cls._prev_db_path
cls._tmp.cleanup()

def _db_path(self, name: str) -> str:
Expand Down Expand Up @@ -122,6 +127,50 @@ def test_same_challenge_nonce_rejected_on_same_node_replay(self):
self.assertEqual(conn.execute("SELECT COUNT(*) FROM used_nonces").fetchone()[0], 1)
self.assertEqual(conn.execute("SELECT COUNT(*) FROM nonces").fetchone()[0], 0)

def test_client_timestamp_cannot_bypass_challenge_validation(self):
mod, db_path = self._load_module("rustchain_attest_node_bypass", "bypass.db")

payload = {
"miner": "RTC_REPLAY_POC_MINER",
"report": {
"nonce": "b" * 64,
"commitment": "deadbeef",
"server_time": 1700000000,
},
"device": {"family": "x86_64", "arch": "default", "model": "poc-box", "cores": 4},
"signals": {"hostname": "poc-host", "macs": []},
"fingerprint": {},
}

status, body = self._submit(mod, payload)

self.assertEqual(status, 409)
self.assertEqual(body["code"], "CHALLENGE_INVALID")

with sqlite3.connect(db_path) as conn:
self.assertEqual(conn.execute("SELECT COUNT(*) FROM nonces").fetchone()[0], 0)
self.assertEqual(conn.execute("SELECT COUNT(*) FROM used_nonces").fetchone()[0], 0)

def test_submit_rejects_arbitrary_nonce_without_server_challenge(self):
mod, db_path = self._load_module("rustchain_attest_node_plain", "plain.db")

payload = {
"miner": "RTC_REPLAY_POC_MINER",
"report": {"nonce": "legacy-local-nonce", "commitment": "deadbeef"},
"device": {"family": "x86_64", "arch": "default", "model": "poc-box", "cores": 4},
"signals": {"hostname": "poc-host", "macs": []},
"fingerprint": {},
}

status, body = self._submit(mod, payload)

self.assertEqual(status, 409)
self.assertEqual(body["code"], "CHALLENGE_INVALID")

with sqlite3.connect(db_path) as conn:
self.assertEqual(conn.execute("SELECT COUNT(*) FROM nonces").fetchone()[0], 0)
self.assertEqual(conn.execute("SELECT COUNT(*) FROM used_nonces").fetchone()[0], 0)


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