guard/src/engine/challenge.rs
loki5512344 15f474486a
feat!: universal redesign — drop Minecraft stack, single-crate architecture
- remove Java plugins (velocity/paper), dashboard, all MC-specific code
  (handshake, death_code, varint, hostname-HMAC); available in history pre-v0.2
- merge crates/* into one package with src/bin/{rampart,rampart-manager,rampart-cli}
- ProtocolHandler trait + registry (no implementations yet), universal PoW kept
- XDP: universal L3/L4 filter (xdp/core/) + pluggable hook API (xdp/hooks/),
  fix IPv6 saddr bug; clang build verified
- docs: bilingual knowledge base (docs/kb/: attacks x4, defense-levels,
  practice x3), rewrite README/architecture for universal concept
- TODO.md v4.0: <=300-line module limit, competitor benchmark section (ref/)
- deploy/CI/docs cleanup: no MC references, new binary names

cargo build/clippy(-D warnings)/test green (55 tests)
2026-08-24 01:50:22 +02:00

260 lines
8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Универсальный SHA-256 hashcash: генерация challenge, решатель,
//! верификатор и адаптивная сложность.
use crate::metrics;
use rand::RngCore;
use sha2::{Digest, Sha256};
use std::collections::VecDeque;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use subtle::ConstantTimeEq;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
const CHALLENGE_TTL_SECS: u64 = 30;
const MAX_NONCE_LEN: usize = 64;
const ALLOWED_HEX_PREFIX: [u8; 4] = *b"0123";
/// Активная PoW-задача, выданная одному соединению.
pub struct Challenge {
token: [u8; 32],
created_at: Instant,
difficulty: u8,
used: bool,
}
impl Challenge {
#[must_use]
pub fn generate(difficulty: u8) -> Self {
let mut token = [0u8; 32];
rand::thread_rng().fill_bytes(&mut token);
Self {
token,
created_at: Instant::now(),
difficulty,
used: false,
}
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.created_at.elapsed().as_secs() >= CHALLENGE_TTL_SECS
}
#[must_use]
pub fn challenge_string(&self) -> String {
hex::encode(self.token)
}
/// Проверяет nonce и помечает challenge использованным при успехе.
#[must_use]
pub fn verify(&mut self, nonce: &str) -> bool {
if self.used || self.is_expired() || nonce.len() > MAX_NONCE_LEN {
return false;
}
let input = format!("{}{nonce}", self.challenge_string());
let hash = Sha256::digest(input.as_bytes());
let hex_hash = hex::encode(hash);
let d = self.difficulty as usize;
let ok = hex_hash.as_bytes().iter().take(d).all(|c| {
let r = c.ct_eq(&ALLOWED_HEX_PREFIX[0])
| c.ct_eq(&ALLOWED_HEX_PREFIX[1])
| c.ct_eq(&ALLOWED_HEX_PREFIX[2])
| c.ct_eq(&ALLOWED_HEX_PREFIX[3]);
r.unwrap_u8() == 1
});
if ok {
self.used = true;
}
ok
}
}
/// Переборный решатель hashcash (для клиентов и тестов).
#[must_use]
pub fn solve(challenge: &str, difficulty: u8) -> Option<String> {
let d = difficulty as usize;
for nonce in 0..u64::MAX {
let nonce_str = nonce.to_string();
let input = format!("{challenge}{nonce_str}");
let hash = Sha256::digest(input.as_bytes());
let hex_hash = hex::encode(hash);
if hex_hash
.as_bytes()
.iter()
.take(d)
.all(|c| ALLOWED_HEX_PREFIX.contains(c))
{
return Some(nonce_str);
}
}
None
}
/// Адаптирует сложность PoW к текущему темпу подключений.
pub struct DifficultyAdjuster {
window: VecDeque<Instant>,
min: u8,
max: u8,
current: u8,
}
impl Default for DifficultyAdjuster {
fn default() -> Self {
Self::new(4, 10)
}
}
impl DifficultyAdjuster {
#[must_use]
pub fn new(min: u8, max: u8) -> Self {
Self {
window: VecDeque::new(),
min: min.max(4),
max: max.min(10),
current: min.max(4),
}
}
pub fn record_connection(&mut self) {
let now = Instant::now();
self.window.push_back(now);
while let Some(&t) = self.window.front() {
if now.duration_since(t).as_secs() >= 1 {
self.window.pop_front();
} else {
break;
}
}
let new_diff = self.compute_difficulty();
if self.current != new_diff {
tracing::info!(
old = self.current,
new = new_diff,
window = self.window.len(),
"pow: difficulty adjusted"
);
self.current = new_diff;
metrics::POW_CURRENT_DIFFICULTY.set(self.current as i64);
}
}
#[must_use]
pub fn current_difficulty(&self) -> u8 {
metrics::POW_CURRENT_DIFFICULTY.set(self.current as i64);
self.current
}
fn compute_difficulty(&self) -> u8 {
match self.window.len() {
cps if cps > 500 => self.max.max(self.min),
cps if cps > 200 => 8,
cps if cps > 50 => 6,
_ => self.min,
}
}
}
/// Проводит текстовый PoW-gate в потоке: выдаёт challenge и проверяет ответ.
///
/// # Errors
/// Возвращает ошибку только при сбое ввода-вывода; неверное решение — `Ok(false)`.
pub async fn enforce(stream: &mut TcpStream, peer_ip: IpAddr, difficulty: u8) -> anyhow::Result<bool> {
if difficulty == 0 {
tracing::debug!("pow: difficulty 0, skipping for {peer_ip}");
return Ok(true);
}
let mut challenge = Challenge::generate(difficulty);
let line = format!("{}\n", challenge.challenge_string());
stream.write_all(line.as_bytes()).await?;
let mut buf = [0u8; MAX_NONCE_LEN + 1];
let n = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut buf)).await??;
if n == 0 {
tracing::debug!("pow: no response from {peer_ip}");
return Ok(false);
}
let nonce = std::str::from_utf8(&buf[..n.min(MAX_NONCE_LEN)]).unwrap_or("").trim();
if nonce.is_empty() || nonce.len() > MAX_NONCE_LEN {
tracing::debug!("pow: invalid nonce from {peer_ip}");
return Ok(false);
}
let valid = challenge.verify(nonce);
tracing::debug!(
"pow: verification {} for {peer_ip}",
if valid { "passed" } else { "failed" }
);
Ok(valid)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn solve_then_verify_roundtrip() {
let mut challenge = Challenge::generate(2);
let nonce = solve(&challenge.challenge_string(), 2).expect("solver should find a nonce");
assert!(challenge.verify(&nonce), "verifier must accept solver output");
}
#[test]
fn verify_rejects_wrong_nonce() {
let mut challenge = Challenge::generate(4);
let challenge_str = challenge.challenge_string();
// Подбираем nonce, который независимо рассчитанный SHA-256
// заведомо отклоняет — тест детерминирован.
let mut wrong_nonce = None;
for i in 0..10_000u32 {
let candidate = format!("bad-{i}");
let hex_hash = hex::encode(Sha256::digest(format!("{challenge_str}{candidate}").as_bytes()));
if !hex_hash
.as_bytes()
.iter()
.take(4)
.all(|c| ALLOWED_HEX_PREFIX.contains(c))
{
wrong_nonce = Some(candidate);
break;
}
}
let wrong_nonce = wrong_nonce.expect("a failing nonce must exist among candidates");
assert!(!challenge.verify(&wrong_nonce));
}
#[test]
fn verify_rejects_replay() {
let mut challenge = Challenge::generate(2);
let nonce = solve(&challenge.challenge_string(), 2).expect("solved");
assert!(challenge.verify(&nonce));
assert!(!challenge.verify(&nonce), "challenge must be single-use");
}
#[test]
fn verify_rejects_oversized_nonce() {
let mut challenge = Challenge::generate(2);
let big_nonce = "0".repeat(MAX_NONCE_LEN + 1);
assert!(!challenge.verify(&big_nonce));
}
#[test]
fn adjuster_raises_difficulty_under_load() {
let mut adjuster = DifficultyAdjuster::new(4, 10);
for _ in 0..600 {
adjuster.record_connection();
}
assert_eq!(adjuster.current_difficulty(), 10);
}
#[test]
fn adjuster_stays_minimal_when_idle() {
let mut adjuster = DifficultyAdjuster::new(4, 10);
adjuster.record_connection();
assert_eq!(adjuster.current_difficulty(), 4);
}
}