diff --git a/miners/pico_bridge/pico_bridge_miner.py b/miners/pico_bridge/pico_bridge_miner.py index aedffeca1..2380785d7 100644 --- a/miners/pico_bridge/pico_bridge_miner.py +++ b/miners/pico_bridge/pico_bridge_miner.py @@ -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...") diff --git a/miners/pico_bridge/tests/test_pico_bridge_miner.py b/miners/pico_bridge/tests/test_pico_bridge_miner.py index e2734a0cd..a39e1798d 100644 --- a/miners/pico_bridge/tests/test_pico_bridge_miner.py +++ b/miners/pico_bridge/tests/test_pico_bridge_miner.py @@ -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(): @@ -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) @@ -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 diff --git a/node/rustchain_v2_integrated_v2.2.1_rip200.py b/node/rustchain_v2_integrated_v2.2.1_rip200.py index a632176b5..4849798c2 100644 --- a/node/rustchain_v2_integrated_v2.2.1_rip200.py +++ b/node/rustchain_v2_integrated_v2.2.1_rip200.py @@ -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)") @@ -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( @@ -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 "" @@ -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), @@ -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 @@ -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 = { @@ -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, diff --git a/node/tests/test_attest_nonce_replay.py b/node/tests/test_attest_nonce_replay.py index 8b7cc8983..863a3fc0a 100644 --- a/node/tests/test_attest_nonce_replay.py +++ b/node/tests/test_attest_nonce_replay.py @@ -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) @@ -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: @@ -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__": diff --git a/node/tests/test_attest_submit_challenge_binding.py b/node/tests/test_attest_submit_challenge_binding.py index 4ae858d45..df85cf544 100644 --- a/node/tests/test_attest_submit_challenge_binding.py +++ b/node/tests/test_attest_submit_challenge_binding.py @@ -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: @@ -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: @@ -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() diff --git a/tests/test_attestation_fuzz.py b/tests/test_attestation_fuzz.py index 7658e92ab..e8bdda409 100644 --- a/tests/test_attestation_fuzz.py +++ b/tests/test_attestation_fuzz.py @@ -42,6 +42,16 @@ def _init_attestation_db(db_path: Path) -> None: miner_id TEXT PRIMARY KEY, pubkey_hex TEXT ); + CREATE TABLE nonces ( + nonce TEXT PRIMARY KEY, + expires_at INTEGER + ); + CREATE TABLE used_nonces ( + nonce TEXT PRIMARY KEY, + miner_id TEXT NOT NULL, + first_seen INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); CREATE TABLE tickets ( ticket_id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, @@ -112,6 +122,13 @@ def _base_payload() -> dict: } +def _attach_live_challenge(client, payload: dict) -> dict: + response = client.post("/attest/challenge", json={}) + assert response.status_code == 200 + payload["report"]["nonce"] = response.get_json()["nonce"] + return payload + + def _client_fixture(monkeypatch, *, strict_security_path=False): local_tmp_dir = Path(__file__).parent / ".tmp_attestation" local_tmp_dir.mkdir(exist_ok=True) @@ -245,8 +262,8 @@ def test_attest_submit_strict_fixture_rejects_malformed_fingerprint(strict_clien def test_attest_submit_strict_fixture_enforces_hardware_binding(strict_client): - first = _base_payload() - second = _base_payload() + first = _attach_live_challenge(strict_client, _base_payload()) + second = _attach_live_challenge(strict_client, _base_payload()) second["miner"] = "different-miner" second["report"]["nonce"] = "nonce-456" # unique nonce to bypass replay check diff --git a/tests/test_epoch_settlement_formal.py b/tests/test_epoch_settlement_formal.py index 3ae15b9d1..64d39cc9d 100644 --- a/tests/test_epoch_settlement_formal.py +++ b/tests/test_epoch_settlement_formal.py @@ -46,8 +46,8 @@ def create_test_db(miners): - fd, db_path = tempfile.mkstemp(suffix=".db") - os.close(fd) + db_fd, db_path = tempfile.mkstemp(suffix=".db") + os.close(db_fd) conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute(""" diff --git a/tests/test_rip201_bucket_spoof.py b/tests/test_rip201_bucket_spoof.py index 20819d73d..758bf4071 100644 --- a/tests/test_rip201_bucket_spoof.py +++ b/tests/test_rip201_bucket_spoof.py @@ -53,6 +53,16 @@ def _init_attestation_db(db_path: Path) -> None: miner_id TEXT PRIMARY KEY, pubkey_hex TEXT ); + CREATE TABLE nonces ( + nonce TEXT PRIMARY KEY, + expires_at INTEGER + ); + CREATE TABLE used_nonces ( + nonce TEXT PRIMARY KEY, + miner_id TEXT NOT NULL, + first_seen INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); CREATE TABLE tickets ( ticket_id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, @@ -157,6 +167,13 @@ def _spoofed_g4_payload(miner: str) -> dict: } +def _attach_live_challenge(client, payload: dict) -> dict: + response = client.post("/attest/challenge", json={}) + assert response.status_code == 200 + payload["report"]["nonce"] = response.get_json()["nonce"] + return payload + + def _verified_g4_fingerprint() -> dict: return { "checks": { @@ -225,7 +242,7 @@ def test_validate_fingerprint_data_accepts_verified_g4_claim(): def test_attestation_downgrades_spoofed_g4_claim_to_non_vintage_weight(attest_client): client, db_path = attest_client - payload = _spoofed_g4_payload("spoof-g4-accepted") + payload = _attach_live_challenge(client, _spoofed_g4_payload("spoof-g4-accepted")) response = client.post( "/attest/submit", @@ -256,7 +273,7 @@ def test_attestation_downgrades_spoofed_g4_claim_to_non_vintage_weight(attest_cl def test_public_apis_do_not_expose_spoofed_claim_as_vintage(attest_client): client, _db_path = attest_client - payload = _spoofed_g4_payload("spoof-g4-public-api") + payload = _attach_live_challenge(client, _spoofed_g4_payload("spoof-g4-public-api")) response = client.post( "/attest/submit", diff --git a/tests/test_rip201_fleet_bypass.py b/tests/test_rip201_fleet_bypass.py index 0369108fd..142e6f3f6 100644 --- a/tests/test_rip201_fleet_bypass.py +++ b/tests/test_rip201_fleet_bypass.py @@ -54,6 +54,16 @@ def _init_attestation_db(db_path: Path) -> None: miner_id TEXT PRIMARY KEY, pubkey_hex TEXT ); + CREATE TABLE nonces ( + nonce TEXT PRIMARY KEY, + expires_at INTEGER + ); + CREATE TABLE used_nonces ( + nonce TEXT PRIMARY KEY, + miner_id TEXT NOT NULL, + first_seen INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); CREATE TABLE tickets ( ticket_id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, @@ -180,9 +190,16 @@ def _shared_fleet_fingerprint() -> dict: } +def _attach_live_challenge(client, payload: dict) -> dict: + response = client.post("/attest/challenge", json={}) + assert response.status_code == 200 + payload["report"]["nonce"] = response.get_json()["nonce"] + return payload + + def test_client_ip_from_request_ignores_spoofed_x_forwarded_for(attest_client): client, db_path = attest_client - payload = { + payload = _attach_live_challenge(client, { "miner": "spoof-demo-1", "device": { "device_family": "x86", @@ -201,7 +218,7 @@ def test_client_ip_from_request_ignores_spoofed_x_forwarded_for(attest_client): "commitment": "commitment-001", }, "fingerprint": _minimal_valid_fingerprint(0.05), - } + }) response = client.post( "/attest/submit", @@ -226,7 +243,7 @@ def test_client_ip_from_request_ignores_spoofed_x_forwarded_for(attest_client): def test_client_ip_from_request_ignores_spoofed_x_real_ip_from_untrusted_peer(attest_client): client, db_path = attest_client - payload = { + payload = _attach_live_challenge(client, { "miner": "spoof-demo-2", "device": { "device_family": "x86", @@ -245,7 +262,7 @@ def test_client_ip_from_request_ignores_spoofed_x_real_ip_from_untrusted_peer(at "commitment": "commitment-002", }, "fingerprint": _minimal_valid_fingerprint(0.06), - } + }) response = client.post( "/attest/submit", @@ -269,7 +286,7 @@ def test_client_ip_from_request_ignores_spoofed_x_real_ip_from_untrusted_peer(at def test_client_ip_from_request_accepts_x_real_ip_from_trusted_proxy(attest_client, monkeypatch): client, db_path = attest_client monkeypatch.setenv("RC_TRUSTED_PROXY_IPS", "127.0.0.1/32,::1/128") - payload = { + payload = _attach_live_challenge(client, { "miner": "proxy-demo-1", "device": { "device_family": "x86", @@ -288,7 +305,7 @@ def test_client_ip_from_request_accepts_x_real_ip_from_trusted_proxy(attest_clie "commitment": "commitment-003", }, "fingerprint": _minimal_valid_fingerprint(0.07), - } + }) response = client.post( "/attest/submit", diff --git a/tests/test_wallet_review_holds.py b/tests/test_wallet_review_holds.py index 4049eb3a4..b4ad81514 100644 --- a/tests/test_wallet_review_holds.py +++ b/tests/test_wallet_review_holds.py @@ -30,6 +30,16 @@ def _init_attestation_db(db_path: Path) -> None: miner_id TEXT PRIMARY KEY, pubkey_hex TEXT ); + CREATE TABLE nonces ( + nonce TEXT PRIMARY KEY, + expires_at INTEGER + ); + CREATE TABLE used_nonces ( + nonce TEXT PRIMARY KEY, + miner_id TEXT NOT NULL, + first_seen INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); CREATE TABLE tickets ( ticket_id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, @@ -99,6 +109,13 @@ def _base_payload(miner: str = "review-miner") -> dict: } +def _attach_live_challenge(test_client, payload: dict) -> dict: + response = test_client.post("/attest/challenge", json={}) + assert response.status_code == 200 + payload["report"]["nonce"] = response.get_json()["nonce"] + return payload + + @pytest.fixture def client(monkeypatch): local_tmp_dir = Path(__file__).parent / ".tmp_attestation" @@ -143,7 +160,7 @@ def test_wallet_review_hold_returns_coaching_response(client): ) conn.commit() - response = test_client.post("/attest/submit", json=_base_payload()) + response = test_client.post("/attest/submit", json=_attach_live_challenge(test_client, _base_payload())) assert response.status_code == 409 body = response.get_json() @@ -171,7 +188,7 @@ def test_wallet_review_release_restores_attestation_flow(client): assert response.status_code == 200 assert response.get_json()["status"] == "released" - response = test_client.post("/attest/submit", json=_base_payload()) + response = test_client.post("/attest/submit", json=_attach_live_challenge(test_client, _base_payload())) assert response.status_code == 200 assert response.get_json()["ok"] is True @@ -189,7 +206,7 @@ def test_wallet_review_escalation_hard_blocks_attestation(client): ) conn.commit() - response = test_client.post("/attest/submit", json=_base_payload()) + response = test_client.post("/attest/submit", json=_attach_live_challenge(test_client, _base_payload())) assert response.status_code == 403 body = response.get_json()