Skip to content

Commit 487ed78

Browse files
LaphoqueRCB1tor
andauthored
security: RIP-201 Bucket Normalization Spoofing — attack + defense (#1957)
Closes: Scottcjn/rustchain-bounties#554 Problem (liu971227-sys / Rustchain#551): classify_miner_bucket() trusted raw client-reported device_arch with no cross-validation. A modern x86 machine could claim device_arch=G4 and get routed into vintage_powerpc (2.5× multiplier) instead of modern (1.0×). Changes to fleet_immune_system.py: 1. Added arch_validation_results DB table to persist server-side validation outcomes (miner, claimed_arch, validated, validation_score, bucket). 2. Added run_arch_validation_for_attestation() — integration hook to call after fingerprint collection. Uses arch_cross_validation.validate_arch_ consistency() and stores result in DB. Score < 0.70 → 'modern' bucket. 3. Added store_arch_validation_result() and get_validated_bucket() helpers. 4. Modified classify_miner_bucket(arch, db=None, miner_id=None): - With db+miner_id: reads server-validated bucket from arch_validation_ results table. No DB record or failed validation → 'modern' (safe default). - Without db/miner_id: legacy raw-arch lookup (backwards compatible). 5. Updated classify_miner_bucket() call sites in: - calculate_immune_rewards_equal_split() (primary reward path) - calculate_immune_weights() pressure mode - compute_bucket_pressure() All now pass db+miner_id to use validated buckets. New file tests/test_bucket_spoof_fix.py (6 tests, all passing): 1. Intel Xeon + G4 claim → rejected, lands in 'modern' bucket 2. Real G4 fingerprint + G4 claim → accepted, vintage_powerpc bucket 3. Modern x86 faking AltiVec (has_sse=True) → rejected 4. Unvalidated miner (no record) → defaults to 'modern' (no bonus) 5. Legacy classify_miner_bucket(arch) still works unchanged 6. store/retrieve validation result round-trip GitHub: @B1tor RTC Wallet: RTC2fe3c33c77666ff76a1cd0999fd4466ee81250ff Co-authored-by: B1tor <b1tor@users.noreply.github.com>
1 parent b4c9c5e commit 487ed78

2 files changed

Lines changed: 574 additions & 5 deletions

File tree

rips/python/rustchain/fleet_immune_system.py

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,19 @@
135135
detection_signals TEXT, -- JSON: which signals triggered
136136
cumulative_score REAL DEFAULT 0.0
137137
);
138+
139+
-- RIP-201 fix: Architecture cross-validation results (Bounty #554)
140+
-- Stores server-side validated arch for each miner so classify_miner_bucket()
141+
-- uses verified hardware class, not the raw client-reported device_arch.
142+
CREATE TABLE IF NOT EXISTS arch_validation_results (
143+
miner TEXT PRIMARY KEY,
144+
claimed_arch TEXT NOT NULL,
145+
validated BOOLEAN NOT NULL DEFAULT 0, -- 1 = passed cross-validation
146+
validation_score REAL DEFAULT 0.0, -- 0.0–1.0 from arch_cross_validation
147+
validated_bucket TEXT, -- bucket assigned after validation
148+
rejection_reason TEXT, -- Why validation failed (if any)
149+
validated_at INTEGER NOT NULL -- Unix timestamp
150+
);
138151
"""
139152

140153

@@ -144,6 +157,128 @@ def ensure_schema(db: sqlite3.Connection):
144157
db.commit()
145158

146159

160+
# ═══════════════════════════════════════════════════════════
161+
# RIP-201 FIX: ARCH CROSS-VALIDATION INTEGRATION (Bounty #554)
162+
# ═══════════════════════════════════════════════════════════
163+
164+
# Minimum validation score required to trust a vintage bucket claim.
165+
# Below this threshold the miner is downgraded to "modern" (no bonus).
166+
ARCH_VALIDATION_SCORE_THRESHOLD = 0.70
167+
168+
169+
def store_arch_validation_result(
170+
db: sqlite3.Connection,
171+
miner: str,
172+
claimed_arch: str,
173+
validation_score: float,
174+
passed: bool,
175+
validated_bucket: str,
176+
rejection_reason: Optional[str] = None,
177+
) -> None:
178+
"""
179+
Persist arch cross-validation outcome for a miner.
180+
181+
Called immediately after validate_arch_consistency() in the attestation
182+
flow so that classify_miner_bucket() can make server-side decisions.
183+
"""
184+
ensure_schema(db)
185+
db.execute("""
186+
INSERT OR REPLACE INTO arch_validation_results
187+
(miner, claimed_arch, validated, validation_score,
188+
validated_bucket, rejection_reason, validated_at)
189+
VALUES (?, ?, ?, ?, ?, ?, ?)
190+
""", (miner, claimed_arch, int(passed), round(validation_score, 4),
191+
validated_bucket, rejection_reason, int(time.time())))
192+
db.commit()
193+
194+
195+
def run_arch_validation_for_attestation(
196+
db: sqlite3.Connection,
197+
miner: str,
198+
claimed_arch: str,
199+
fingerprint: dict,
200+
device_info: Optional[dict] = None,
201+
) -> Tuple[bool, str]:
202+
"""
203+
Run arch_cross_validation against a miner's fingerprint and store the result.
204+
205+
This is the integration hook that must be called from the attestation
206+
submission flow (submit_attestation / record_attestation_success) AFTER
207+
fingerprint data is collected.
208+
209+
Returns:
210+
(passed: bool, validated_bucket: str)
211+
212+
Side-effects:
213+
Writes result to arch_validation_results table.
214+
"""
215+
try:
216+
from node.arch_cross_validation import validate_arch_consistency, normalize_arch
217+
except ImportError:
218+
try:
219+
import sys, os
220+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'node'))
221+
from arch_cross_validation import validate_arch_consistency, normalize_arch
222+
except ImportError:
223+
# If arch_cross_validation is unavailable, fail safe — no bonus
224+
store_arch_validation_result(
225+
db, miner, claimed_arch, 0.0, False, "modern",
226+
"arch_cross_validation module unavailable"
227+
)
228+
return False, "modern"
229+
230+
score, details = validate_arch_consistency(fingerprint, claimed_arch, device_info or {})
231+
passed = score >= ARCH_VALIDATION_SCORE_THRESHOLD
232+
233+
# Derive the validated bucket:
234+
# Only grant the claimed bucket if validation passed AND the arch maps to
235+
# a non-modern bucket (i.e., a bonus bucket). Otherwise fall back to "modern".
236+
raw_bucket = ARCH_TO_BUCKET.get(claimed_arch.lower(), "modern")
237+
validated_bucket = raw_bucket if passed else "modern"
238+
239+
rejection_reason = None
240+
if not passed:
241+
issues = details.get("issues", [])
242+
rejection_reason = "; ".join(issues[:5]) if issues else f"score {score:.3f} below threshold"
243+
244+
store_arch_validation_result(
245+
db, miner, claimed_arch, score, passed, validated_bucket, rejection_reason
246+
)
247+
return passed, validated_bucket
248+
249+
250+
def get_validated_bucket(
251+
db: sqlite3.Connection,
252+
miner: str,
253+
claimed_arch: str,
254+
) -> str:
255+
"""
256+
Look up the server-validated bucket for a miner.
257+
258+
If no validated record exists (miner hasn't been through arch validation)
259+
or validation failed, returns "modern" (safe default — no unearned bonus).
260+
261+
This is the core of the Bounty #554 fix: bucket classification is now
262+
derived from server-side validated data, not the raw client claim.
263+
"""
264+
ensure_schema(db)
265+
row = db.execute("""
266+
SELECT validated, validated_bucket
267+
FROM arch_validation_results
268+
WHERE miner = ? AND claimed_arch = ?
269+
""", (miner, claimed_arch.lower())).fetchone()
270+
271+
if row is None:
272+
# No validation record → treat as unvalidated → modern bucket (no bonus)
273+
return "modern"
274+
275+
validated, validated_bucket = row
276+
if not validated or not validated_bucket:
277+
return "modern"
278+
279+
return validated_bucket
280+
281+
147282
# ═══════════════════════════════════════════════════════════
148283
# SIGNAL COLLECTION (called from submit_attestation)
149284
# ═══════════════════════════════════════════════════════════
@@ -480,8 +615,28 @@ def compute_fleet_scores(
480615
# BUCKET NORMALIZATION
481616
# ═══════════════════════════════════════════════════════════
482617

483-
def classify_miner_bucket(device_arch: str) -> str:
484-
"""Map a device architecture to its hardware bucket."""
618+
def classify_miner_bucket(
619+
device_arch: str,
620+
db: Optional[sqlite3.Connection] = None,
621+
miner_id: Optional[str] = None,
622+
) -> str:
623+
"""
624+
Map a device architecture to its hardware bucket.
625+
626+
RIP-201 / Bounty #554 fix: when a DB connection and miner_id are provided,
627+
bucket assignment is derived from the server-side arch_validation_results
628+
rather than the raw client-reported device_arch. A miner claiming G4 but
629+
whose fingerprint matches x86 will have validation_score < threshold and
630+
will receive the "modern" bucket (1.0× multiplier) instead of
631+
"vintage_powerpc" (2.5× multiplier).
632+
633+
Falls back to the legacy lookup when called without DB context (backwards
634+
compatible for callers that don't yet pass DB / miner_id).
635+
"""
636+
if db is not None and miner_id is not None:
637+
return get_validated_bucket(db, miner_id, device_arch)
638+
# Legacy path: trust the client-reported arch (only used when validation
639+
# context is unavailable — callers should migrate to pass db+miner_id).
485640
return ARCH_TO_BUCKET.get(device_arch.lower(), "modern")
486641

487642

@@ -511,7 +666,8 @@ def compute_bucket_pressure(
511666
bucket_miners = defaultdict(list)
512667

513668
for miner_id, arch, weight in miners:
514-
bucket = classify_miner_bucket(arch)
669+
# RIP-201 fix: use server-validated bucket when DB is available
670+
bucket = classify_miner_bucket(arch, db=db, miner_id=miner_id)
515671
bucket_counts[bucket] += 1
516672
bucket_weights[bucket] += weight
517673
bucket_miners[bucket].append(miner_id)
@@ -639,7 +795,8 @@ def calculate_immune_rewards_equal_split(
639795
base = get_time_aged_multiplier(arch, chain_age_years)
640796
fleet_score = fleet_scores.get(miner_id, 0.0)
641797
effective = apply_fleet_decay(base, fleet_score)
642-
bucket = classify_miner_bucket(arch)
798+
# RIP-201 fix: use server-validated bucket, not raw client-reported arch
799+
bucket = classify_miner_bucket(arch, db=db, miner_id=miner_id)
643800
buckets[bucket].append((miner_id, effective))
644801

645802
# Record
@@ -765,9 +922,10 @@ def calculate_immune_weights(
765922
pressure = compute_bucket_pressure(decayed_weights, epoch, db)
766923

767924
# Step 5: Apply pressure to get final weights
925+
# RIP-201 fix: use server-validated bucket, not raw client-reported arch
768926
final_weights = {}
769927
for miner_id, arch, weight in decayed_weights:
770-
bucket = classify_miner_bucket(arch)
928+
bucket = classify_miner_bucket(arch, db=db, miner_id=miner_id)
771929
bucket_factor = pressure.get(bucket, 1.0)
772930
final_weights[miner_id] = weight * bucket_factor
773931

0 commit comments

Comments
 (0)