From 9913263c3cd10fbf2a110a92cf327fbb17a7bb11 Mon Sep 17 00:00:00 2001 From: createkr Date: Thu, 26 Feb 2026 09:35:42 +0000 Subject: [PATCH] fix: harden entropy collision check against sparse overlap bypass\n\nFixes Scottcjn/Rustchain#396 --- node/hardware_binding_v2.py | 38 ++++++++++----- tests/test_hardware_binding_v2_security.py | 55 ++++++++++++++++++++++ 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/node/hardware_binding_v2.py b/node/hardware_binding_v2.py index 35520067..06efce15 100755 --- a/node/hardware_binding_v2.py +++ b/node/hardware_binding_v2.py @@ -14,6 +14,7 @@ DB_PATH = os.environ.get('RUSTCHAIN_DB_PATH') or os.environ.get('DB_PATH') or '/root/rustchain/rustchain_v2.db' ENTROPY_TOLERANCE = 0.30 # 30% tolerance for entropy drift MIN_COMPARABLE_FIELDS = 3 # require at least 3 non-zero entropy fields for quality +CORE_ENTROPY_FIELDS = ['clock_cv', 'cache_l1', 'cache_l2', 'thermal_ratio', 'jitter_cv'] def init_hardware_bindings_v2(): """Create the v2 bindings table with entropy profiles.""" @@ -61,6 +62,14 @@ def extract_entropy_profile(fingerprint: dict) -> Dict: return profile + +def _count_nonzero_fields(profile: Dict) -> int: + return sum(1 for k in CORE_ENTROPY_FIELDS if float(profile.get(k, 0)) > 0) + + +def _count_comparable_nonzero_fields(stored: Dict, current: Dict) -> int: + return sum(1 for k in CORE_ENTROPY_FIELDS if float(stored.get(k, 0)) > 0 and float(current.get(k, 0)) > 0) + def compare_entropy_profiles(stored: Dict, current: Dict) -> Tuple[bool, float, str]: """ Compare two entropy profiles. @@ -88,11 +97,12 @@ def compare_entropy_profiles(stored: Dict, current: Dict) -> Tuple[bool, float, count = 0 hard_fails = 0 - for key in ['clock_cv', 'cache_l1', 'cache_l2', 'thermal_ratio', 'jitter_cv']: + for key in CORE_ENTROPY_FIELDS: stored_val = float(stored.get(key, 0)) current_val = float(current.get(key, 0)) - if stored_val > 0: + # Compare only when BOTH sides provide non-zero signal for this field. + if stored_val > 0 and current_val > 0: diff = abs(stored_val - current_val) / stored_val field_tol = FIELD_TOLERANCE.get(key, ENTROPY_TOLERANCE) total_diff += min(diff, 1.0) # Cap at 100% for averaging @@ -106,12 +116,12 @@ def compare_entropy_profiles(stored: Dict, current: Dict) -> Tuple[bool, float, # FIX: Handle no-fingerprint miners (both profiles are zeros) if count == 0: - current_count = sum(1 for key in ['clock_cv', 'cache_l1', 'cache_l2', 'thermal_ratio', 'jitter_cv'] - if float(current.get(key, 0)) > 0) + current_count = _count_nonzero_fields(current) if current_count == 0: return True, 1.0, 'no_fingerprint_data' else: - return True, 0.5, 'stored_empty_current_has_data' + # No overlapping comparable fields; caller should treat as low-confidence comparison. + return True, 0.5, 'insufficient_comparable_overlap' avg_diff = total_diff / count similarity = 1.0 - avg_diff @@ -133,8 +143,7 @@ def check_entropy_collision(entropy_profile: Dict, exclude_serial: str = None) - Sparse profiles are considered low-quality and are ignored for collision matching. """ # Count non-zero fields in current profile - nonzero_fields = sum(1 for k in ['clock_cv', 'cache_l1', 'cache_l2', 'thermal_ratio', 'jitter_cv'] - if float(entropy_profile.get(k, 0)) > 0) + nonzero_fields = _count_nonzero_fields(entropy_profile) if nonzero_fields < MIN_COMPARABLE_FIELDS: # Not enough entropy data to detect collisions reliably @@ -152,15 +161,19 @@ def check_entropy_collision(entropy_profile: Dict, exclude_serial: str = None) - if stored_json: stored = json.loads(stored_json) # Also require stored profile to have enough data - stored_nonzero = sum(1 for k in ['clock_cv', 'cache_l1', 'cache_l2', 'thermal_ratio', 'jitter_cv'] - if float(stored.get(k, 0)) > 0) + stored_nonzero = _count_nonzero_fields(stored) if stored_nonzero < MIN_COMPARABLE_FIELDS: continue + comparable_nonzero = _count_comparable_nonzero_fields(stored, entropy_profile) + if comparable_nonzero < MIN_COMPARABLE_FIELDS: + # Sparse overlap is too weak for collision decisions. + continue + is_similar, score, _ = compare_entropy_profiles(stored, entropy_profile) - # Require stronger confidence; do not let highly-tolerant clock_cv dominate. - if is_similar and score > 0.97: # Very similar on sufficiently rich profiles + # Require stronger confidence on sufficiently rich, comparable profiles. + if is_similar and score > 0.97: return serial_hash # Collision detected! return None @@ -193,8 +206,7 @@ def bind_hardware_v2( if row is None: # NEW HARDWARE - enforce entropy quality first - nonzero_fields = sum(1 for k in ['clock_cv', 'cache_l1', 'cache_l2', 'thermal_ratio', 'jitter_cv'] - if float(entropy_profile.get(k, 0)) > 0) + nonzero_fields = _count_nonzero_fields(entropy_profile) if nonzero_fields < MIN_COMPARABLE_FIELDS: return False, 'entropy_insufficient', { 'error': 'Entropy profile quality too low for secure binding', diff --git a/tests/test_hardware_binding_v2_security.py b/tests/test_hardware_binding_v2_security.py index bff68b8b..6a1e0e6e 100644 --- a/tests/test_hardware_binding_v2_security.py +++ b/tests/test_hardware_binding_v2_security.py @@ -60,3 +60,58 @@ def test_detect_collision_with_rich_entropy_profiles(tmp_path): assert not ok2 assert reason2 == 'entropy_collision' assert 'collision_hash' in details2 + + +def test_collision_check_requires_min_comparable_overlap(tmp_path): + db = tmp_path / 'hb.db' + hb.DB_PATH = str(db) + hb.init_hardware_bindings_v2() + + # Baseline binding with rich profile + fp_base = _mk_fingerprint(clock=0.20, l1=100.0, l2=220.0, thermal=1.8, jitter=0.07) + ok, reason, _ = hb.bind_hardware_v2( + serial='SER-BASE-2', + wallet='RTCwalletBase2', + arch='x86_64', + cores=8, + fingerprint=fp_base, + ) + assert ok and reason == 'new_binding' + + # Sparse-overlap payload: three non-zero fields, but only one overlaps with baseline (clock_cv) + # This must NOT be used for collision decisions. + crafted = { + 'clock_cv': 0.20, # overlaps + 'cache_l1': 0.0, # no overlap + 'cache_l2': 0.0, # no overlap + 'thermal_ratio': 0.0, # no overlap + 'jitter_cv': 0.30, # non-zero but not present in stored if attacker manipulates payloads + } + + # Force one more non-overlap non-zero to satisfy input quality gate + crafted['cache_l1'] = 0.01 + + # Make stored comparable overlap effectively < MIN by editing stored profile directly + with sqlite3.connect(str(db)) as conn: + conn.execute( + "UPDATE hardware_bindings_v2 SET entropy_profile = ? WHERE serial_hash = ?", + ( + json.dumps({'clock_cv': 0.21, 'cache_l1': 0, 'cache_l2': 0, 'thermal_ratio': 0, 'jitter_cv': 0}), + hb.compute_serial_hash('SER-BASE-2', 'x86_64'), + ), + ) + conn.commit() + + collision = hb.check_entropy_collision(crafted) + assert collision is None + + +def test_compare_entropy_profiles_marks_sparse_overlap_low_confidence(): + stored = {'clock_cv': 0.2, 'cache_l1': 0, 'cache_l2': 0, 'thermal_ratio': 0, 'jitter_cv': 0} + current = {'clock_cv': 0.21, 'cache_l1': 0.0, 'cache_l2': 0.0, 'thermal_ratio': 0.0, 'jitter_cv': 0.3} + + ok, score, reason = hb.compare_entropy_profiles(stored, current) + assert ok + assert reason in ('entropy_ok', 'insufficient_comparable_overlap') + # comparable overlap is only one field; ensure score does not imply a strong multi-signal match + assert score <= 1.0