Skip to content

Commit e92090f

Browse files
authored
feat: require server-issued challenges for attestation (#1756)
Removes insecure local nonce fallback. Miners now fail-closed without server challenge. Hardens attestation protocol. Contributor: @Mavline — 75 RTC (security hardening)
1 parent 247af3a commit e92090f

10 files changed

Lines changed: 223 additions & 94 deletions

miners/pico_bridge/pico_bridge_miner.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -565,10 +565,10 @@ def run_attestation_cycle(self) -> bool:
565565
print(f"[INFO] Fetching challenge from {node_url}...")
566566
nonce = fetch_challenge(node_url, miner_name)
567567
if not nonce:
568-
nonce = hashlib.sha256(f"{miner_name}_{time.time()}".encode()).hexdigest()
569-
print(f"[WARN] Using locally generated nonce: {nonce[:16]}...")
570-
else:
571-
print(f"[INFO] Received nonce: {nonce[:16]}...")
568+
print("[ERROR] Attestation challenge unavailable; refusing insecure local nonce fallback.")
569+
return False
570+
571+
print(f"[INFO] Received nonce: {nonce[:16]}...")
572572

573573
# Step 2: Send challenge to Pico/console
574574
print("[INFO] Sending challenge to Pico bridge...")

miners/pico_bridge/tests/test_pico_bridge_miner.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616

1717
from pico_bridge_miner import (
1818
PicoSimulator,
19+
PicoBridgeMiner,
1920
build_attestation_payload,
2021
CONSOLE_PROFILES,
2122
)
23+
import pico_bridge_miner
2224

2325

2426
def test_pico_simulator_connection():
@@ -235,6 +237,59 @@ def test_console_profiles_complete():
235237
print("✓ test_console_profiles_complete passed")
236238

237239

240+
def test_attestation_cycle_fails_closed_without_challenge():
241+
"""Test miner refuses insecure local nonce fallback when challenge fetch fails."""
242+
243+
class StubBridge:
244+
def __init__(self):
245+
self.send_called = False
246+
self.read_called = False
247+
248+
def send_challenge(self, nonce):
249+
self.send_called = True
250+
return True
251+
252+
def read_attestation(self, timeout_sec=30.0):
253+
self.read_called = True
254+
return {"board_id": "should-not-be-read"}
255+
256+
miner = PicoBridgeMiner(
257+
{
258+
"console_type": "n64_mips",
259+
"simulation_mode": True,
260+
"node_url": "https://example.invalid",
261+
"miner_name": "test-pico",
262+
"wallet_id": "RTCtest123",
263+
}
264+
)
265+
bridge = StubBridge()
266+
miner.bridge = bridge
267+
268+
original_fetch = pico_bridge_miner.fetch_challenge
269+
original_submit = pico_bridge_miner.submit_attestation
270+
submit_called = {"value": False}
271+
272+
try:
273+
pico_bridge_miner.fetch_challenge = lambda node_url, miner_name: None
274+
275+
def _unexpected_submit(node_url, payload):
276+
submit_called["value"] = True
277+
return True, "unexpected"
278+
279+
pico_bridge_miner.submit_attestation = _unexpected_submit
280+
281+
result = miner.run_attestation_cycle()
282+
283+
assert result is False
284+
assert not bridge.send_called
285+
assert not bridge.read_called
286+
assert not submit_called["value"]
287+
print("✓ test_attestation_cycle_fails_closed_without_challenge passed")
288+
finally:
289+
pico_bridge_miner.fetch_challenge = original_fetch
290+
pico_bridge_miner.submit_attestation = original_submit
291+
292+
238293
def run_all_tests():
239294
"""Run all Pico bridge miner tests."""
240295
print("=" * 60)
@@ -251,6 +306,7 @@ def run_all_tests():
251306
test_build_attestation_payload_checks,
252307
test_build_attestation_payload_emulation_detection,
253308
test_console_profiles_complete,
309+
test_attestation_cycle_fails_closed_without_challenge,
254310
]
255311

256312
passed = 0

node/rustchain_v2_integrated_v2.2.1_rip200.py

Lines changed: 11 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -344,10 +344,6 @@ def _normalize_attestation_report(report):
344344
return normalized
345345

346346

347-
ATTEST_NONCE_SKEW_SECONDS = int(os.getenv("ATTEST_NONCE_SKEW_SECONDS", "60"))
348-
_ATTEST_CHALLENGE_NONCE_RE = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE)
349-
350-
351347
def attest_ensure_tables(conn):
352348
"""Create the attestation nonce tables expected by replay protection."""
353349
conn.execute("CREATE TABLE IF NOT EXISTS nonces (nonce TEXT PRIMARY KEY, expires_at INTEGER)")
@@ -373,29 +369,8 @@ def attest_cleanup_expired(conn, now_ts: Optional[int] = None):
373369
conn.commit()
374370

375371

376-
def extract_attestation_timestamp(data: dict, report: dict, nonce: Optional[str]) -> Optional[int]:
377-
"""Extract an optional attestation timestamp from request payload fields."""
378-
for source in (report or {}, data or {}):
379-
for field_name in ("nonce_ts", "nonce_timestamp", "timestamp", "server_time"):
380-
raw_value = source.get(field_name)
381-
if isinstance(raw_value, bool):
382-
continue
383-
if isinstance(raw_value, (int, float)):
384-
if math.isfinite(raw_value):
385-
return int(raw_value)
386-
continue
387-
if isinstance(raw_value, str) and raw_value.strip().isdigit():
388-
return int(raw_value.strip())
389-
return None
390-
391-
392-
def _attest_nonce_requires_challenge(nonce: str, nonce_ts: Optional[int]) -> bool:
393-
"""Current challenge endpoint emits 64-hex nonces with no embedded timestamp."""
394-
return nonce_ts is None and bool(_ATTEST_CHALLENGE_NONCE_RE.fullmatch(nonce))
395-
396-
397372
def attest_validate_challenge(conn, nonce: str, now_ts: Optional[int] = None):
398-
"""Validate and consume a one-time challenge nonce."""
373+
"""Validate and consume a one-time challenge nonce from the active node store."""
399374
now_ts = int(time.time()) if now_ts is None else int(now_ts)
400375
attest_cleanup_expired(conn, now_ts=now_ts)
401376
row = conn.execute(
@@ -421,10 +396,8 @@ def attest_validate_and_store_nonce(
421396
miner: str,
422397
nonce: str,
423398
now_ts: Optional[int] = None,
424-
nonce_ts: Optional[int] = None,
425-
skew_seconds: int = ATTEST_NONCE_SKEW_SECONDS,
426399
):
427-
"""Reject replayed or stale attestation nonces and persist accepted ones."""
400+
"""Require a live server-issued challenge and persist accepted attestation nonces."""
428401
now_ts = int(time.time()) if now_ts is None else int(now_ts)
429402
nonce = _attest_text(nonce)
430403
miner = _attest_valid_miner(miner) or _attest_text(miner) or ""
@@ -439,15 +412,11 @@ def attest_validate_and_store_nonce(
439412
if replay_row:
440413
return False, "nonce_replay", None
441414

442-
challenge_expires_at = None
443-
if _attest_nonce_requires_challenge(nonce, nonce_ts):
444-
ok, err, challenge_expires_at = attest_validate_challenge(conn, nonce, now_ts=now_ts)
445-
if not ok:
446-
return False, err, None
447-
elif nonce_ts is not None and abs(int(nonce_ts) - now_ts) > max(int(skew_seconds), 0):
448-
return False, "nonce_stale", None
415+
ok, err, challenge_expires_at = attest_validate_challenge(conn, nonce, now_ts=now_ts)
416+
if not ok:
417+
return False, err, None
449418

450-
expires_at = challenge_expires_at or (now_ts + max(int(skew_seconds), 1))
419+
expires_at = int(challenge_expires_at)
451420
conn.execute(
452421
"INSERT INTO used_nonces (nonce, miner_id, first_seen, expires_at) VALUES (?, ?, ?, ?)",
453422
(nonce, miner, now_ts, expires_at),
@@ -2481,7 +2450,11 @@ def miner_dashboard_page():
24812450

24822451
@app.route('/attest/challenge', methods=['POST'])
24832452
def get_challenge():
2484-
"""Issue challenge for hardware attestation"""
2453+
"""Issue challenge for hardware attestation.
2454+
2455+
Deployments with multiple attestation backends should keep submit traffic
2456+
sticky to the issuing node or share the nonce store across nodes.
2457+
"""
24852458
nonce = secrets.token_hex(32)
24862459
expires = int(time.time()) + 300 # 5 minutes
24872460

@@ -2629,14 +2602,12 @@ def _submit_attestation_impl():
26292602
"code": "MISSING_NONCE"
26302603
}), 400
26312604

2632-
nonce_ts = extract_attestation_timestamp(data, report, nonce)
26332605
with sqlite3.connect(DB_PATH) as nonce_conn:
26342606
nonce_ok, nonce_err, _ = attest_validate_and_store_nonce(
26352607
nonce_conn,
26362608
miner=miner,
26372609
nonce=nonce,
26382610
now_ts=int(time.time()),
2639-
nonce_ts=nonce_ts,
26402611
)
26412612
if not nonce_ok:
26422613
nonce_messages = {
@@ -2650,11 +2621,6 @@ def _submit_attestation_impl():
26502621
"Attestation nonce has already been used",
26512622
"NONCE_REPLAY",
26522623
),
2653-
"nonce_stale": (
2654-
"nonce_stale",
2655-
"Attestation nonce timestamp is outside the allowed skew window",
2656-
"NONCE_STALE",
2657-
),
26582624
}
26592625
error_name, message, code = nonce_messages.get(
26602626
nonce_err,

node/tests/test_attest_nonce_replay.py

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ def _conn(self):
4545

4646
def test_nonce_replay_rejected(self):
4747
with self._conn() as conn:
48+
conn.execute("INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)", ("nonce-1", 1100))
4849
ok, err, _ = self.mod.attest_validate_and_store_nonce(
4950
conn,
5051
miner="miner-1",
5152
nonce="nonce-1",
5253
now_ts=1000,
53-
nonce_ts=1000,
5454
)
5555
self.assertTrue(ok)
5656
self.assertIsNone(err)
@@ -60,49 +60,43 @@ def test_nonce_replay_rejected(self):
6060
miner="miner-1",
6161
nonce="nonce-1",
6262
now_ts=1001,
63-
nonce_ts=1001,
6463
)
6564
self.assertFalse(ok)
6665
self.assertEqual(err, "nonce_replay")
6766

68-
def test_nonce_freshness_with_skew_window(self):
67+
def test_attestation_requires_server_issued_challenge(self):
6968
with self._conn() as conn:
7069
ok, err, _ = self.mod.attest_validate_and_store_nonce(
7170
conn,
7271
miner="miner-1",
7372
nonce="nonce-stale",
7473
now_ts=1000,
75-
nonce_ts=900,
76-
skew_seconds=60,
7774
)
7875
self.assertFalse(ok)
79-
self.assertEqual(err, "nonce_stale")
76+
self.assertEqual(err, "challenge_invalid")
8077

78+
def test_expired_challenge_is_rejected(self):
79+
with self._conn() as conn:
80+
conn.execute("INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)", ("expired-challenge", 950))
8181
ok, err, _ = self.mod.attest_validate_and_store_nonce(
8282
conn,
8383
miner="miner-1",
84-
nonce="nonce-fresh",
84+
nonce="expired-challenge",
8585
now_ts=1000,
86-
nonce_ts=950,
87-
skew_seconds=60,
8886
)
89-
self.assertTrue(ok)
90-
self.assertIsNone(err)
87+
self.assertFalse(ok)
88+
self.assertEqual(err, "challenge_invalid")
9189

92-
def test_hex_nonce_without_timestamp_is_backward_compatible(self):
90+
def test_challenge_style_nonce_cannot_bypass_with_client_timestamp(self):
9391
with self._conn() as conn:
94-
nonce_ts = self.mod.extract_attestation_timestamp({}, {}, "a7f1c4e9")
95-
self.assertIsNone(nonce_ts)
96-
9792
ok, err, _ = self.mod.attest_validate_and_store_nonce(
9893
conn,
99-
miner="miner-legacy",
100-
nonce="a7f1c4e9",
94+
miner="miner-1",
95+
nonce="b" * 64,
10196
now_ts=1000,
102-
nonce_ts=nonce_ts,
10397
)
104-
self.assertTrue(ok)
105-
self.assertIsNone(err)
98+
self.assertFalse(ok)
99+
self.assertEqual(err, "challenge_invalid")
106100

107101
def test_challenge_is_one_time(self):
108102
with self._conn() as conn:
@@ -118,21 +112,17 @@ def test_challenge_is_one_time(self):
118112

119113
def test_expired_entries_cleanup(self):
120114
with self._conn() as conn:
115+
conn.execute(
116+
"INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)",
117+
("old-challenge", 950),
118+
)
121119
conn.execute(
122120
"INSERT INTO used_nonces (nonce, miner_id, first_seen, expires_at) VALUES (?, ?, ?, ?)",
123121
("old-nonce", "miner-1", 900, 950),
124122
)
125123
self.mod.attest_cleanup_expired(conn, now_ts=1000)
126-
127-
ok, err, _ = self.mod.attest_validate_and_store_nonce(
128-
conn,
129-
miner="miner-1",
130-
nonce="old-nonce",
131-
now_ts=1000,
132-
nonce_ts=None,
133-
)
134-
self.assertTrue(ok)
135-
self.assertIsNone(err)
124+
self.assertEqual(conn.execute("SELECT COUNT(*) FROM nonces").fetchone()[0], 0)
125+
self.assertEqual(conn.execute("SELECT COUNT(*) FROM used_nonces").fetchone()[0], 0)
136126

137127

138128
if __name__ == "__main__":

node/tests/test_attest_submit_challenge_binding.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class TestAttestSubmitChallengeBinding(unittest.TestCase):
2727
def setUpClass(cls):
2828
cls._tmp = tempfile.TemporaryDirectory()
2929
cls._prev_admin_key = os.environ.get("RC_ADMIN_KEY")
30+
cls._prev_db_path = os.environ.get("RUSTCHAIN_DB_PATH")
3031
os.environ["RC_ADMIN_KEY"] = "0123456789abcdef0123456789abcdef"
3132

3233
if NODE_DIR not in sys.path:
@@ -38,6 +39,10 @@ def tearDownClass(cls):
3839
os.environ.pop("RC_ADMIN_KEY", None)
3940
else:
4041
os.environ["RC_ADMIN_KEY"] = cls._prev_admin_key
42+
if cls._prev_db_path is None:
43+
os.environ.pop("RUSTCHAIN_DB_PATH", None)
44+
else:
45+
os.environ["RUSTCHAIN_DB_PATH"] = cls._prev_db_path
4146
cls._tmp.cleanup()
4247

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

130+
def test_client_timestamp_cannot_bypass_challenge_validation(self):
131+
mod, db_path = self._load_module("rustchain_attest_node_bypass", "bypass.db")
132+
133+
payload = {
134+
"miner": "RTC_REPLAY_POC_MINER",
135+
"report": {
136+
"nonce": "b" * 64,
137+
"commitment": "deadbeef",
138+
"server_time": 1700000000,
139+
},
140+
"device": {"family": "x86_64", "arch": "default", "model": "poc-box", "cores": 4},
141+
"signals": {"hostname": "poc-host", "macs": []},
142+
"fingerprint": {},
143+
}
144+
145+
status, body = self._submit(mod, payload)
146+
147+
self.assertEqual(status, 409)
148+
self.assertEqual(body["code"], "CHALLENGE_INVALID")
149+
150+
with sqlite3.connect(db_path) as conn:
151+
self.assertEqual(conn.execute("SELECT COUNT(*) FROM nonces").fetchone()[0], 0)
152+
self.assertEqual(conn.execute("SELECT COUNT(*) FROM used_nonces").fetchone()[0], 0)
153+
154+
def test_submit_rejects_arbitrary_nonce_without_server_challenge(self):
155+
mod, db_path = self._load_module("rustchain_attest_node_plain", "plain.db")
156+
157+
payload = {
158+
"miner": "RTC_REPLAY_POC_MINER",
159+
"report": {"nonce": "legacy-local-nonce", "commitment": "deadbeef"},
160+
"device": {"family": "x86_64", "arch": "default", "model": "poc-box", "cores": 4},
161+
"signals": {"hostname": "poc-host", "macs": []},
162+
"fingerprint": {},
163+
}
164+
165+
status, body = self._submit(mod, payload)
166+
167+
self.assertEqual(status, 409)
168+
self.assertEqual(body["code"], "CHALLENGE_INVALID")
169+
170+
with sqlite3.connect(db_path) as conn:
171+
self.assertEqual(conn.execute("SELECT COUNT(*) FROM nonces").fetchone()[0], 0)
172+
self.assertEqual(conn.execute("SELECT COUNT(*) FROM used_nonces").fetchone()[0], 0)
173+
125174

126175
if __name__ == "__main__":
127176
unittest.main()

0 commit comments

Comments
 (0)