diff --git a/node/beacon_api.py b/node/beacon_api.py index 3389f99c5..9d6fc7484 100644 --- a/node/beacon_api.py +++ b/node/beacon_api.py @@ -536,7 +536,7 @@ def update_contract(contract_id): return jsonify({'error': 'Missing X-Agent-Key header — authentication required'}), 401 from_agent = contract['from_agent'] - to_agent = contract.get('to_agent', '') + to_agent = contract['to_agent'] # Caller must be either the from_agent or to_agent if agent_key != from_agent and agent_key != to_agent: diff --git a/node/governance.py b/node/governance.py index 64327d986..f1537252e 100644 --- a/node/governance.py +++ b/node/governance.py @@ -24,6 +24,7 @@ """ import hashlib +import hmac import json import logging import sqlite3 @@ -553,7 +554,7 @@ def founder_veto(proposal_id: int): # Admin key is validated via environment variable (not hardcoded) import os expected_key = os.environ.get("RUSTCHAIN_ADMIN_KEY", "") - if not expected_key or admin_key != expected_key: + if not expected_key or not hmac.compare_digest(admin_key, expected_key): return jsonify({"error": "invalid admin_key"}), 403 try: diff --git a/node/rewards_implementation_rip200.py b/node/rewards_implementation_rip200.py index c94e80421..adc02f3e2 100644 --- a/node/rewards_implementation_rip200.py +++ b/node/rewards_implementation_rip200.py @@ -174,6 +174,23 @@ def settle_epoch_rip200(db_path, epoch: int, enable_anti_double_mining: bool = T return result except Exception as e: print(f"[WARN] Anti-double-mining failed, falling back to standard: {e}") + # SECURITY FIX: Rollback any partial writes from the failed + # anti-double-mining path before falling through. Without this, + # the standard path would append its own writes on top of the + # uncommitted partial state, causing a double-spend when commit() + # is called at the end. + try: + db.rollback() + except Exception: + pass + # Re-acquire the transaction lock after rollback + db.execute("BEGIN IMMEDIATE") + # Re-check settled status — another worker may have settled + # while we were rolling back. + st = db.execute("SELECT settled FROM epoch_state WHERE epoch=?", (epoch,)).fetchone() + if st and int(st[0]) == 1: + db.rollback() + return {"ok": True, "epoch": epoch, "already_settled": True} # Fall through to standard rewards # Standard RIP-200 rewards (no anti-double-mining) diff --git a/node/rustchain_sync_endpoints.py b/node/rustchain_sync_endpoints.py index f501d4c2c..77f1e438e 100644 --- a/node/rustchain_sync_endpoints.py +++ b/node/rustchain_sync_endpoints.py @@ -92,7 +92,7 @@ def require_admin(f): @wraps(f) def decorated(*args, **kwargs): key = request.headers.get("X-Admin-Key") or request.headers.get("X-API-Key") - if not key or key != admin_key: + if not key or not hmac.compare_digest(key, admin_key): return jsonify({"error": "Unauthorized"}), 401 return f(*args, **kwargs) diff --git a/node/sophia_attestation_inspector.py b/node/sophia_attestation_inspector.py index 93542d91c..ec2d8df52 100644 --- a/node/sophia_attestation_inspector.py +++ b/node/sophia_attestation_inspector.py @@ -15,6 +15,7 @@ import time import sqlite3 import hashlib +import hmac import argparse import logging import traceback @@ -701,7 +702,7 @@ def register_sophia_endpoints(app, db_path: str = None): def _is_admin(req): need = os.environ.get("RC_ADMIN_KEY", "") got = req.headers.get("X-Admin-Key", "") or req.headers.get("X-API-Key", "") - return bool(need and got and need == got) + return bool(need and got and hmac.compare_digest(got, need)) @app.route("/sophia/status/", methods=["GET"]) def sophia_status_miner(miner_id): diff --git a/node/sophia_governor_review_service.py b/node/sophia_governor_review_service.py index 79e01c5f3..fc44924e8 100644 --- a/node/sophia_governor_review_service.py +++ b/node/sophia_governor_review_service.py @@ -10,6 +10,7 @@ from __future__ import annotations +import hmac import json import os import re @@ -142,7 +143,7 @@ def _is_authorized(req) -> bool: required_admin = os.getenv("RC_ADMIN_KEY", "").strip() if required_admin: provided_admin = (req.headers.get("X-Admin-Key") or req.headers.get("X-API-Key") or "").strip() - if provided_admin == required_admin: + if hmac.compare_digest(provided_admin, required_admin): return True auth_header = (req.headers.get("Authorization") or "").strip() diff --git a/tests/test_beacon_atlas_behavior.py b/tests/test_beacon_atlas_behavior.py index e4dd538bb..612bb6cad 100644 --- a/tests/test_beacon_atlas_behavior.py +++ b/tests/test_beacon_atlas_behavior.py @@ -33,8 +33,10 @@ def setUpClass(cls): cls.app.config['DB_PATH'] = cls.test_db_path # Import blueprint routes manually to avoid teardown_appcontext issue - from node.beacon_api import DB_PATH, init_beacon_tables - + from node import beacon_api as beacon_module + from node.beacon_api import init_beacon_tables + beacon_module.DB_PATH = cls.test_db_path + # Register blueprint from node import beacon_api as beacon_module cls.app.register_blueprint(beacon_module.beacon_api) @@ -53,7 +55,24 @@ def teardown_request(exception): # Initialize database tables init_beacon_tables(cls.test_db_path) - + + # Seed relay_agents so from_agent lookup in create_contract succeeds + with sqlite3.connect(cls.test_db_path) as conn: + now = int(time.time()) + test_agents = [ + ('bcn_alice_test', 'deadbeef' * 8, 'Alice', 'active', None, now, now), + ('bcn_bob_test', 'cafecafe' * 8, 'Bob', 'active', None, now, now), + ('bcn_test_from', 'facb00fb' * 8, 'From', 'active', None, now, now), + ('bcn_test_to', 'beefbabe' * 8, 'To', 'active', None, now, now), + ] + conn.executemany( + "INSERT OR IGNORE INTO relay_agents " + "(agent_id, pubkey_hex, name, status, coinbase_address, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + test_agents, + ) + conn.commit() + cls.client = cls.app.test_client() @classmethod @@ -95,30 +114,32 @@ def test_create_contract_workflow(self): create_response = self.client.post( '/api/contracts', data=json.dumps(contract_data), - content_type='application/json' + content_type='application/json', + headers={'X-Agent-Key': 'bcn_alice_test'}, ) self.assertEqual(create_response.status_code, 201) - + created = json.loads(create_response.data) self.assertIn('id', created) self.assertEqual(created['from'], 'bcn_alice_test') self.assertEqual(created['to'], 'bcn_bob_test') self.assertEqual(created['state'], 'offered') - + contract_id = created['id'] - + # Verify contract appears in list list_response = self.client.get('/api/contracts') self.assertEqual(list_response.status_code, 200) contracts = json.loads(list_response.data) self.assertEqual(len(contracts), 1) self.assertEqual(contracts[0]['id'], contract_id) - - # Update contract state to active + + # Update contract state to active (only to_agent can accept) update_response = self.client.put( f'/api/contracts/{contract_id}', data=json.dumps({'state': 'active'}), - content_type='application/json' + content_type='application/json', + headers={'X-Agent-Key': 'bcn_bob_test'}, ) self.assertEqual(update_response.status_code, 200) @@ -249,15 +270,17 @@ def test_invalid_state_update_rejected(self): create_response = self.client.post( '/api/contracts', data=json.dumps(contract_data), - content_type='application/json' + content_type='application/json', + headers={'X-Agent-Key': 'bcn_test_from'}, ) contract_id = json.loads(create_response.data)['id'] - - # Try invalid state + + # Try invalid state (caller must be from_agent or to_agent) update_response = self.client.put( f'/api/contracts/{contract_id}', data=json.dumps({'state': 'invalid_state'}), - content_type='application/json' + content_type='application/json', + headers={'X-Agent-Key': 'bcn_test_from'}, ) self.assertEqual(update_response.status_code, 400) @@ -489,4 +512,4 @@ def run_tests(): if __name__ == "__main__": - sys.exit(run_tests()) + sys.exit(run_tests()) \ No newline at end of file