Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ curl -sk https://rustchain.org/epoch # Current epoch
curl -sSL https://raw.githubusercontent.com/Scottcjn/Rustchain/main/install-miner.sh | bash

# Dry-run: test hardware fingerprint without mining
rustchain-miner --dry-run
python3 ~/.rustchain/rustchain_miner.py --dry-run --wallet test
```

Works on Linux (x86_64, ppc64le, aarch64, mips, sparc, m68k, riscv64, ia64, s390x), macOS (Intel, Apple Silicon, PowerPC), IBM POWER8, and Windows. If it runs Python, it can mine.
Expand Down Expand Up @@ -401,6 +401,11 @@ Named after a 486 laptop with oxidized serial ports that still boots to DOS and
</div>


## Wallets / 钱包

- **Browser Extension**: [RustChain Wallet (Chrome)](https://rustchain.org/wallet)
- **CLI**: `pip install clawrtc` — command-line wallet & miner management

## Contributing
Please read the [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines and the [Bounty Board](https://github.com/Scottcjn/rustchain-bounties) for active tasks and rewards.

Expand Down
2 changes: 1 addition & 1 deletion docs/GPU_FINGERPRINTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,6 @@ Minimum 3 violations even for same-architecture spoofs.

## Reference

- [RIP-0308: Proof of Physical AI](rips/docs/RIP-0308-proof-of-physical-ai.md)
- [RIP-0308: Proof of Physical AI](../rips/docs/RIP-0308-proof-of-physical-ai.md)
- [DOI: 10.5281/zenodo.19442753](https://doi.org/10.5281/zenodo.19442753)
- Khattak & Mikaitis, "Accurate Models of NVIDIA Tensor Cores" (arXiv:2512.07004)
4 changes: 3 additions & 1 deletion install-miner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ detect_platform() {
Linux)
[ "$arch" != "aarch64" ] && [ "$arch" != "x86_64" ] && [ "$arch" != "ppc64le" ] && { echo -e "${RED}[!] Unsupported architecture: $arch (Supported: aarch64, x86_64, ppc64le)${NC}"; exit 1; }
if grep -qi "raspberry" /proc/cpuinfo 2>/dev/null; then echo "rpi"; else echo "linux"; fi ;;
Darwin) echo "macos" ;;
Darwin)
[ "$arch" != "x86_64" ] && [ "$arch" != "arm64" ] && { echo -e "${RED}[!] Unsupported macOS architecture: $arch (Supported: x86_64, arm64)${NC}"; exit 1; }
echo "macos" ;;
*) echo "unknown"; exit 1 ;;
esac
}
Expand Down
36 changes: 35 additions & 1 deletion miners/linux/rustchain_linux_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ def get_linux_serial():

class LocalMiner:
def __init__(self, wallet=None, wart_address=None, wart_pool=None,
bzminer_path=None, manage_bzminer=False):
bzminer_path=None, manage_bzminer=False, verbose=False, show_payload=False):
self.node_url = NODE_URL
self.verbose = verbose
self.show_payload = show_payload
self.wallet = wallet or self._gen_wallet()
self.hw_info = {}
self.enrolled = False
Expand Down Expand Up @@ -464,13 +466,41 @@ def dry_run(self):
else:
print("[DRY-RUN] Fingerprint checks available: no")

# Verbose: show attest/enroll API payloads
if self.verbose or self.show_payload:
print("\n[DRY-RUN] === API Payload Preview ===")
# Simulate attest payload
attest_payload = {
"wallet": self.wallet,
"hostname": self.hw_info.get("hostname", ""),
"cpu": self.hw_info.get("cpu", ""),
"cores": self.hw_info.get("cores", 0),
"memory_gb": self.hw_info.get("memory_gb", 0),
"serial": self.hw_info.get("serial", ""),
"macs": self.hw_info.get("macs", []),
}
print(f"[DRY-RUN] POST {self.node_url}/api/attest")
print(f"[DRY-RUN] Payload: {json.dumps(attest_payload, indent=2)}")

# Simulate enroll payload
enroll_payload = {
"wallet": self.wallet,
"hostname": self.hw_info.get("hostname", ""),
"fingerprint": self.fingerprint_data if self.fingerprint_data else "pending",
}
print(f"[DRY-RUN] POST {self.node_url}/api/enroll")
print(f"[DRY-RUN] Payload: {json.dumps(enroll_payload, indent=2)}")
print(f"[DRY-RUN] === End Payload Preview ===\n")

# Optional health probe (read-only)
try:
r = requests.get(f"{self.node_url}/health", timeout=8, verify=TLS_VERIFY)
print(f"[DRY-RUN] Health probe: HTTP {r.status_code}")
if r.ok:
data = r.json()
print(f"[DRY-RUN] Node version: {data.get('version', 'n/a')}")
if self.verbose:
print(f"[DRY-RUN] Health response: {json.dumps(data, indent=2)}")
except Exception as e:
print(f"[DRY-RUN] Health probe failed: {e}")

Expand Down Expand Up @@ -528,6 +558,8 @@ def mine(self):
parser.add_argument("--bzminer-path", help="Path to BzMiner binary")
parser.add_argument("--manage-bzminer", action="store_true", help="Auto-start/stop BzMiner")
parser.add_argument("--dry-run", action="store_true", help="Run preflight checks only; do not start mining")
parser.add_argument("--verbose", action="store_true", help="Enable verbose output in dry-run mode")
parser.add_argument("--show-payload", action="store_true", help="Show API request payload in dry-run mode")
args = parser.parse_args()

miner = LocalMiner(
Expand All @@ -536,6 +568,8 @@ def mine(self):
wart_pool=args.wart_pool,
bzminer_path=args.bzminer_path,
manage_bzminer=args.manage_bzminer,
verbose=args.verbose,
show_payload=args.show_payload,
)
if args.dry_run:
miner.dry_run()
Expand Down
4 changes: 3 additions & 1 deletion miners/windows/install-miner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ detect_platform() {
Linux)
[ "$arch" != "aarch64" ] && [ "$arch" != "x86_64" ] && [ "$arch" != "ppc64le" ] && { echo -e "${RED}[!] Unsupported architecture: $arch (ARM64 only for Pi)${NC}"; exit 1; }
if grep -qi "raspberry" /proc/cpuinfo 2>/dev/null; then echo "rpi"; else echo "linux"; fi ;;
Darwin) echo "macos" ;;
Darwin)
[ "$arch" != "x86_64" ] && [ "$arch" != "arm64" ] && { echo -e "${RED}[!] Unsupported macOS architecture: $arch (Supported: x86_64, arm64)${NC}"; exit 1; }
echo "macos" ;;
*) echo "unknown"; exit 1 ;;
esac
}
Expand Down
3 changes: 2 additions & 1 deletion node/beacon_x402.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import json
import logging
import hmac
import os
import sqlite3
import time
Expand Down Expand Up @@ -176,7 +177,7 @@ def set_agent_wallet(agent_id):
expected = os.environ.get("BEACON_ADMIN_KEY", "")
if not expected:
return _cors_json({"error": "Admin key not configured"}, 503)
if admin_key != expected:
if not hmac.compare_digest(admin_key, expected):
return _cors_json({"error": "Unauthorized — admin key required"}, 401)

data = request.get_json(silent=True) or {}
Expand Down
50 changes: 48 additions & 2 deletions node/claims_eligibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,55 @@ def is_epoch_settled(
"""
Check if epoch has been settled

Epochs are typically settled within 1-2 epochs after completion.
For simplicity, we consider an epoch settled if we're at least 2 epochs past it.
Priority order:
1. Check epoch_state.settled in database (authoritative source)
2. Fallback to epoch_state.finalized for legacy schemas
3. Time-based heuristic only when database has no record for this epoch

Security fix (#3960): Previously ignored db_path entirely, allowing claims
for epochs that were never actually settled (e.g., settlement failed,
rolled back, or had no eligible miners).
"""
# First, try to check the database for authoritative settlement status
try:
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()

# Check if epoch_state table exists and has a 'settled' column
try:
cursor.execute("""
SELECT settled FROM epoch_state WHERE epoch = ?
""", (epoch,))
row = cursor.fetchone()

if row is not None:
# Database has a record for this epoch - use it as authoritative
return bool(row[0])

# No row yet - settlement may be in progress, fall back to time heuristic
# This handles the case where settlement hasn't run yet for recent epochs

except sqlite3.OperationalError:
# Column 'settled' doesn't exist, try legacy 'finalized' column
try:
cursor.execute("""
SELECT finalized FROM epoch_state WHERE epoch = ?
""", (epoch,))
row = cursor.fetchone()

if row is not None:
return bool(row[0])

except sqlite3.OperationalError:
# epoch_state table doesn't exist at all, fall back to time heuristic
pass

except sqlite3.Error:
# Database unavailable, fall back to time heuristic
pass

# Fallback: time-based heuristic for epochs without database records
# Consider an epoch settled if we're at least 2 epochs past it
settled_epoch = max(0, current_slot // 144 - 2)
return epoch <= settled_epoch

Expand Down
5 changes: 3 additions & 2 deletions node/governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,9 +551,10 @@ def founder_veto(proposal_id: int):
reason = data.get("reason", "Security-critical change").strip()

# Admin key is validated via environment variable (not hardcoded)
import os
import hmac
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:
Expand Down
6 changes: 3 additions & 3 deletions node/machine_passport_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ def create_passport():
admin_key = request.headers.get('X-Admin-Key', '') or request.headers.get('X-API-Key', '')
expected_admin_key = os.environ.get('ADMIN_KEY', '')

if expected_admin_key and admin_key != expected_admin_key:
return jsonify({
if expected_admin_key and not hmac.compare_digest(admin_key, expected_admin_key):
return jsonify({
'ok': False,
'error': 'unauthorized',
'message': 'Admin key required',
Expand Down Expand Up @@ -284,7 +284,7 @@ def update_passport(machine_id: str):

# Check authorization
if expected_admin_key:
if admin_key != expected_admin_key:
if not hmac.compare_digest(admin_key, expected_admin_key):
# Allow owner to update their own passport
data = request.get_json()
if data and data.get('owner_miner_id') != passport.owner_miner_id:
Expand Down
8 changes: 6 additions & 2 deletions node/rustchain_p2p_gossip.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,8 +500,10 @@ def _signed_content(msg_type: str, sender_id: str, msg_id: str, ttl: int, payloa
def create_message(self, msg_type: MessageType, payload: Dict, ttl: int = GOSSIP_TTL) -> GossipMessage:
"""Create a new gossip message"""
# Generate msg_id first for signature binding (Issue #2272)
# Issue #2268: Use cryptographically secure random nonce instead of predictable time.time()
temp_content = f"{msg_type.value}:{self.node_id}:{json.dumps(payload, sort_keys=True)}"
msg_id = hashlib.sha256(f"{temp_content}:{time.time()}".encode()).hexdigest()[:24]
secure_nonce = secrets.token_hex(16) # 128-bit cryptographically secure random value
msg_id = hashlib.sha256(f"{temp_content}:{secure_nonce}".encode()).hexdigest()[:24]

content = self._signed_content(msg_type.value, self.node_id, msg_id, ttl, payload)
sig, ts = self._sign_message(content)
Expand Down Expand Up @@ -937,8 +939,10 @@ def _handle_get_state(self, msg: GossipMessage) -> Dict:
# Uses the Phase A signed-content shape (msg_type:sender_id:payload)
# so verify_message() on the requester side accepts it.
payload = {"state": state_data}
# Issue #2268: Use cryptographically secure random nonce instead of predictable time.time()
state_nonce = secrets.token_hex(16)
state_msg_id = hashlib.sha256(
f"STATE:{self.node_id}:{json.dumps(payload, sort_keys=True)}:{time.time()}".encode()
f"STATE:{self.node_id}:{json.dumps(payload, sort_keys=True)}:{state_nonce}".encode()
).hexdigest()[:24]

content = self._signed_content(MessageType.STATE.value, self.node_id, state_msg_id, 0, payload)
Expand Down
2 changes: 1 addition & 1 deletion node/rustchain_sync_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
79 changes: 77 additions & 2 deletions node/rustchain_v2_integrated_v2.2.1_rip200.py
Original file line number Diff line number Diff line change
Expand Up @@ -1273,6 +1273,15 @@ def init_db():
# Insert default values
c.execute("INSERT OR IGNORE INTO schema_version(version, applied_at) VALUES(17, ?)",
(int(time.time()),))

# API endpoint rate limiting table (Issue #2749)
c.execute("""CREATE TABLE IF NOT EXISTS api_rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_ip TEXT NOT NULL,
endpoint TEXT NOT NULL,
ts INTEGER NOT NULL
)""")
c.execute("CREATE INDEX IF NOT EXISTS idx_api_rate_limits_ip_endpoint_ts ON api_rate_limits(client_ip, endpoint, ts)")
c.execute("INSERT OR IGNORE INTO gov_threshold(id, threshold) VALUES(1, 3)")
c.execute("INSERT OR IGNORE INTO checkpoints_meta(k, v) VALUES('chain_id', 'rustchain-mainnet-candidate')")
# BCOS v2: Blockchain Certified Open Source attestations
Expand Down Expand Up @@ -2577,6 +2586,51 @@ def get_check_status(check_data):



# ── API Endpoint Rate Limiting (Issue #2749 - Security Hardening) ──
# Sliding window rate limiter for public API endpoints to prevent DoS and scraping.
API_RATE_LIMIT = 100 # Max requests per window
API_RATE_WINDOW = 60 # Window size in seconds (1 minute)
API_RATE_CAPTCHA_THRESHOLD = 50 # Require CAPTCHA after this many requests

def check_api_endpoint_rate_limit(client_ip: str, endpoint: str) -> tuple:
"""Sliding window rate limit for API endpoints.

Returns (allowed: bool, remaining: int, retry_after: int, message: str)
"""
now = int(time.time())
cutoff = now - API_RATE_WINDOW

with sqlite3.connect(DB_PATH) as conn:
# Clean expired entries
conn.execute("DELETE FROM api_rate_limits WHERE endpoint = ? AND ts < ?", (endpoint, cutoff))

# Count requests in current window
row = conn.execute(
"SELECT COUNT(*) FROM api_rate_limits WHERE client_ip = ? AND endpoint = ? AND ts >= ?",
(client_ip, endpoint, cutoff)
).fetchone()
request_count = row[0] if row else 0

if request_count >= API_RATE_LIMIT:
# Calculate retry_after from oldest request in window
oldest = conn.execute(
"SELECT MIN(ts) FROM api_rate_limits WHERE client_ip = ? AND endpoint = ? AND ts >= ?",
(client_ip, endpoint, cutoff)
).fetchone()
retry_after = (oldest[0] + API_RATE_WINDOW) - now if oldest else API_RATE_WINDOW
return False, 0, max(1, retry_after), f"rate_limit_exceeded:{request_count}/{API_RATE_LIMIT} per {API_RATE_WINDOW}s"

# Record this request
conn.execute(
"INSERT INTO api_rate_limits (client_ip, endpoint, ts) VALUES (?, ?, ?)",
(client_ip, endpoint, now)
)

remaining = API_RATE_LIMIT - request_count - 1
captcha_required = request_count >= API_RATE_CAPTCHA_THRESHOLD
return True, remaining, 0, "captcha_required" if captcha_required else "ok"


# ── IP Rate Limiting for Attestations (Security Hardening 2026-02-02) ──
# -- IP Rate Limiting for Attestations (SQLite-backed, gunicorn-safe) --
ATTEST_IP_LIMIT = 15 # Max unique miners per IP per hour
Expand Down Expand Up @@ -5429,7 +5483,7 @@ def api_nodes():
def _is_admin() -> bool:
need = os.environ.get("RC_ADMIN_KEY", "")
got = request.headers.get("X-Admin-Key", "") or request.headers.get("X-API-Key", "")
return bool(need and got and need == got)
return bool(need and got and hmac.compare_digest(need, got))

def _should_redact_url(u: str) -> bool:
try:
Expand Down Expand Up @@ -5494,8 +5548,22 @@ def api_miners():
"""
Return list of attested miners with their PoA details.
RIP-200 Bounty #2002: Added Pagination (limit, offset) to prevent DoS.
Issue #2749: Added sliding window rate limiting (100 req/min per IP).
"""
import time as _time

# Issue #2749: Rate limiting check
client_ip = get_client_ip()
allowed, remaining, retry_after, msg = check_api_endpoint_rate_limit(client_ip, "/api/miners")
if not allowed:
response = jsonify({"error": "rate_limit_exceeded", "message": msg, "retry_after": retry_after})
response.status_code = 429
response.headers["Retry-After"] = str(retry_after)
response.headers["X-RateLimit-Limit"] = str(API_RATE_LIMIT)
response.headers["X-RateLimit-Remaining"] = "0"
response.headers["X-RateLimit-Reset"] = str(int(_time.time()) + retry_after)
return response

now = int(_time.time())

# Pagination args
Expand Down Expand Up @@ -5568,7 +5636,7 @@ def api_miners():
"antiquity_multiplier": mult
})

return jsonify({
response = jsonify({
"miners": miners,
"pagination": {
"total": total_count,
Expand All @@ -5577,6 +5645,13 @@ def api_miners():
"count": len(miners)
}
})
# Issue #2749: Add rate limit headers to successful responses
response.headers["X-RateLimit-Limit"] = str(API_RATE_LIMIT)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(now + API_RATE_WINDOW)
if msg == "captcha_required":
response.headers["X-Captcha-Required"] = "true"
return response


@app.route("/api/miner/<miner_id>/streak", methods=["GET"])
Expand Down
Loading
Loading