vanguards_rs/rendguard.rs
1//! Rendezvous point monitoring for detecting statistical attacks.
2//!
3//! This module provides protection against statistical attacks on hidden services
4//! by monitoring rendezvous point usage patterns. It detects when a relay is being
5//! used as a rendezvous point more frequently than expected based on its bandwidth.
6//!
7//! # Overview
8//!
9//! The rendguard system tracks:
10//!
11//! - **Usage counts**: How many times each relay has been used as a rendezvous point
12//! - **Expected weights**: The expected usage based on bandwidth proportion
13//! - **Overuse detection**: When a relay is used more than expected
14//!
15//! # Overuse Detection Flow
16//!
17//! ```text
18//! ┌─────────────────────────────────────────────────────────────────────────┐
19//! │ Rendezvous Point Usage Check │
20//! │ │
21//! │ ┌─────────────────────┐ │
22//! │ │ HS_SERVICE_REND │ │
23//! │ │ Circuit Created │ │
24//! │ └──────────┬──────────┘ │
25//! │ │ │
26//! │ ▼ │
27//! │ ┌─────────────────────┐ │
28//! │ │ Extract RP │ │
29//! │ │ Fingerprint │ │
30//! │ └──────────┬──────────┘ │
31//! │ │ │
32//! │ ▼ │
33//! │ ┌─────────────────────┐ │
34//! │ │ Increment Usage │ │
35//! │ │ Count │ │
36//! │ └──────────┬──────────┘ │
37//! │ │ │
38//! │ ┌────────────────┼────────────────┐ │
39//! │ │ │ │ │
40//! │ ▼ ▼ ▼ │
41//! │ ┌─────────────────┐ ┌───────────┐ ┌─────────────────┐ │
42//! │ │ total_uses < │ │ relay_uses│ │ Check Ratio: │ │
43//! │ │ global_start? │ │ < relay_ │ │ used/total > │ │
44//! │ │ │ │ start? │ │ weight * max? │ │
45//! │ └────────┬────────┘ └─────┬─────┘ └────────┬────────┘ │
46//! │ │ │ │ │
47//! │ ▼ ▼ ▼ │
48//! │ [VALID] [VALID] ┌────────┴────────┐ │
49//! │ │ │ │
50//! │ ▼ ▼ │
51//! │ [OVERUSED] [VALID] │
52//! │ │ │
53//! │ ▼ │
54//! │ ┌─────────────────────┐ │
55//! │ │ Log Warning │ │
56//! │ │ (potential attack) │ │
57//! │ └─────────────────────┘ │
58//! └─────────────────────────────────────────────────────────────────────────┘
59//! ```
60//!
61//! # Attack Detection
62//!
63//! An attacker controlling a relay could try to become the rendezvous point for
64//! a target hidden service more often than expected. This module detects such
65//! statistical anomalies by comparing actual usage to expected bandwidth-weighted
66//! usage.
67//!
68//! ```text
69//! Detection Formula:
70//!
71//! overused = (relay_uses / total_uses) > (relay_weight * max_ratio)
72//!
73//! Where:
74//! relay_uses = Number of times this relay was used as RP
75//! total_uses = Total RP uses across all relays
76//! relay_weight = Relay's bandwidth / total network bandwidth
77//! max_ratio = Configured maximum use-to-bandwidth ratio (default: 5.0)
78//! ```
79//!
80//! # Usage Tracking
81//!
82//! ```text
83//! ┌─────────────────────────────────────────────────────────────────────────┐
84//! │ RendGuard State │
85//! │ │
86//! │ ┌─────────────────────────────────────────────────────────────────┐ │
87//! │ │ use_counts: HashMap<String, RendUseCount> │ │
88//! │ │ │ │
89//! │ │ Fingerprint │ Used │ Weight │ │
90//! │ │ ─────────────────────┼───────┼──────── │ │
91//! │ │ AABBCCDD... │ 15 │ 0.0023 │ │
92//! │ │ EEFF0011... │ 8 │ 0.0015 │ │
93//! │ │ NOT_IN_CONSENSUS │ 2 │ 0.01 (churn allowance) │ │
94//! │ │ ... │ ... │ ... │ │
95//! │ └─────────────────────────────────────────────────────────────────┘ │
96//! │ │
97//! │ total_use_counts: 1250 │
98//! │ │
99//! │ ┌─────────────────────────────────────────────────────────────────┐ │
100//! │ │ Scaling: When total_use_counts >= use_scale_at_count │ │
101//! │ │ All counts are halved to prevent unbounded growth │ │
102//! │ └─────────────────────────────────────────────────────────────────┘ │
103//! └─────────────────────────────────────────────────────────────────────────┘
104//! ```
105//!
106//! # What This Module Does NOT Do
107//!
108//! - **Guard selection**: Use [`crate::vanguards`] for guard management
109//! - **Circuit closure**: Use [`crate::control`] for circuit management
110//! - **Bandwidth monitoring**: Use [`crate::bandguards`] for bandwidth attacks
111//!
112//! # Configuration
113//!
114//! Key configuration options in [`RendguardConfig`](crate::config::RendguardConfig):
115//!
116//! | Option | Default | Description |
117//! |--------|---------|-------------|
118//! | `use_global_start_count` | 1000 | Minimum total uses before checking |
119//! | `use_relay_start_count` | 100 | Minimum relay uses before checking |
120//! | `use_max_use_to_bw_ratio` | 5.0 | Maximum ratio of use to bandwidth |
121//! | `use_scale_at_count` | 20000 | Scale counts when reaching this total |
122//! | `use_max_consensus_weight_churn` | 1.0 | Weight for NOT_IN_CONSENSUS relays |
123//!
124//! # Example
125//!
126//! ```rust
127//! use vanguards_rs::rendguard::{RendGuard, RendUseCount, RendCheckResult};
128//! use vanguards_rs::config::RendguardConfig;
129//!
130//! let mut rendguard = RendGuard::new();
131//! let config = RendguardConfig::default();
132//!
133//! // Simulate relay usage
134//! let fingerprint = "AABBCCDD00112233445566778899AABBCCDDEEFF";
135//!
136//! // Check if usage is valid
137//! let valid = rendguard.valid_rend_use(fingerprint, &config);
138//! if !valid {
139//! let usage_rate = rendguard.usage_rate(fingerprint);
140//! let expected = rendguard.expected_weight(fingerprint);
141//! println!("Overuse detected: {:.2}% vs expected {:.2}%", usage_rate, expected);
142//! }
143//! ```
144//!
145//! # Security Considerations
146//!
147//! - Start counts prevent false positives during initial operation
148//! - Scaling prevents long-running relays from accumulating unfair counts
149//! - NOT_IN_CONSENSUS tracking catches relays that leave the network
150//! - Weight churn allowance handles consensus changes gracefully
151//!
152//! # See Also
153//!
154//! - [`crate::config::RendguardConfig`] - Configuration options
155//! - [`crate::vanguards::RendGuard`] - Main implementation (re-exported here)
156//! - [`crate::vanguards::RendUseCount`] - Per-relay usage tracking
157//! - [Python vanguards rendguard](https://github.com/mikeperry-tor/vanguards) - Original implementation
158
159// Re-export types from vanguards module
160pub use crate::vanguards::{RendGuard, RendUseCount};
161
162/// Identifier used for relays not in the current consensus.
163///
164/// When a relay is used as a rendezvous point but is not found in the
165/// current consensus, its usage is tracked under this special identifier.
166/// This handles cases where relays leave the network or consensus churn.
167pub const NOT_IN_CONSENSUS_ID: &str = "NOT_IN_CONSENSUS";
168
169/// Result of checking a rendezvous point usage.
170///
171/// Returned by usage validation to indicate whether a rendezvous point
172/// selection is valid or represents a potential statistical attack.
173///
174/// # Example
175///
176/// ```rust
177/// use vanguards_rs::rendguard::RendCheckResult;
178///
179/// fn handle_rend_check(result: RendCheckResult) {
180/// match result {
181/// RendCheckResult::Valid => {
182/// println!("RP usage is within expected bounds");
183/// }
184/// RendCheckResult::Overused { fingerprint, usage_rate, expected_weight } => {
185/// println!(
186/// "Potential attack: {} used {:.2}% vs expected {:.2}%",
187/// fingerprint, usage_rate, expected_weight
188/// );
189/// }
190/// }
191/// }
192/// ```
193///
194/// # See Also
195///
196/// - [`RendGuard::valid_rend_use`] - Validation method
197/// - [`RendGuard::is_overused`] - Direct overuse check
198#[derive(Debug, Clone, PartialEq)]
199pub enum RendCheckResult {
200 /// Usage is valid, circuit can proceed.
201 ///
202 /// The relay's usage rate is within acceptable bounds relative to
203 /// its bandwidth weight.
204 Valid,
205 /// Relay is overused, circuit should be closed.
206 ///
207 /// The relay is being used as a rendezvous point more frequently
208 /// than expected based on its bandwidth. This may indicate a
209 /// statistical attack attempting to correlate hidden service activity.
210 Overused {
211 /// The relay's fingerprint (40 hex characters).
212 fingerprint: String,
213 /// Actual usage rate as a percentage of total RP uses.
214 usage_rate: f64,
215 /// Expected weight as a percentage based on bandwidth.
216 expected_weight: f64,
217 },
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::config::RendguardConfig;
224
225 #[test]
226 fn test_rendguard_new() {
227 let rg = RendGuard::new();
228
229 assert!(rg.use_counts.is_empty());
230 assert_eq!(rg.total_use_counts, 0.0);
231 assert_eq!(rg.pickle_revision, 1.0);
232 }
233
234 #[test]
235 fn test_not_in_consensus_tracking() {
236 let mut rg = RendGuard::new();
237 let config = RendguardConfig::default();
238
239 let fp = "7791CA6B67303ACE46C2B6F5211206B765948147";
240
241 for i in 1..config.use_global_start_count {
242 let valid = rg.valid_rend_use(fp, &config);
243 assert!(valid, "Use {} should be valid", i);
244
245 assert!(rg.use_counts.contains_key(NOT_IN_CONSENSUS_ID));
246 assert_eq!(
247 rg.use_counts.get(NOT_IN_CONSENSUS_ID).unwrap().used,
248 i as f64
249 );
250 }
251 }
252
253 #[test]
254 fn test_overuse_detection() {
255 let mut rg = RendGuard::new();
256 let config = RendguardConfig {
257 use_global_start_count: 10,
258 use_relay_start_count: 5,
259 use_max_use_to_bw_ratio: 5.0,
260 ..Default::default()
261 };
262
263 let fp = "BC630CBBB518BE7E9F4E09712AB0269E9DC7D626";
264 rg.use_counts.insert(
265 fp.to_string(),
266 RendUseCount {
267 idhex: fp.to_string(),
268 used: 0.0,
269 weight: 0.01,
270 },
271 );
272
273 for _ in 0..20 {
274 rg.valid_rend_use(fp, &config);
275 }
276
277 let is_overused = rg.is_overused(fp, &config);
278 assert!(is_overused, "Relay should be overused");
279 }
280
281 #[test]
282 fn test_scale_counts() {
283 let mut rg = RendGuard::new();
284
285 rg.use_counts.insert(
286 "A".repeat(40),
287 RendUseCount {
288 idhex: "A".repeat(40),
289 used: 100.0,
290 weight: 0.5,
291 },
292 );
293 rg.use_counts.insert(
294 "B".repeat(40),
295 RendUseCount {
296 idhex: "B".repeat(40),
297 used: 200.0,
298 weight: 0.5,
299 },
300 );
301 rg.total_use_counts = 300.0;
302
303 rg.scale_counts();
304
305 assert_eq!(rg.use_counts.get(&"A".repeat(40)).unwrap().used, 50.0);
306 assert_eq!(rg.use_counts.get(&"B".repeat(40)).unwrap().used, 100.0);
307 assert_eq!(rg.total_use_counts, 150.0);
308 }
309
310 #[test]
311 fn test_usage_rate() {
312 let mut rg = RendGuard::new();
313 let fp = "A".repeat(40);
314
315 rg.use_counts.insert(
316 fp.clone(),
317 RendUseCount {
318 idhex: fp.clone(),
319 used: 25.0,
320 weight: 0.1,
321 },
322 );
323 rg.total_use_counts = 100.0;
324
325 let rate = rg.usage_rate(&fp);
326 assert!((rate - 25.0).abs() < 0.001);
327 }
328
329 #[test]
330 fn test_expected_weight() {
331 let mut rg = RendGuard::new();
332 let fp = "A".repeat(40);
333
334 rg.use_counts.insert(
335 fp.clone(),
336 RendUseCount {
337 idhex: fp.clone(),
338 used: 0.0,
339 weight: 0.05,
340 },
341 );
342
343 let weight = rg.expected_weight(&fp);
344 assert!((weight - 5.0).abs() < 0.001);
345 }
346
347 #[test]
348 fn test_below_global_start_count_not_overused() {
349 let mut rg = RendGuard::new();
350 let config = RendguardConfig {
351 use_global_start_count: 1000,
352 use_relay_start_count: 100,
353 ..Default::default()
354 };
355
356 let fp = "A".repeat(40);
357 rg.use_counts.insert(
358 fp.clone(),
359 RendUseCount {
360 idhex: fp.clone(),
361 used: 500.0,
362 weight: 0.001,
363 },
364 );
365 rg.total_use_counts = 500.0;
366
367 assert!(!rg.is_overused(&fp, &config));
368 }
369
370 #[test]
371 fn test_below_relay_start_count_not_overused() {
372 let mut rg = RendGuard::new();
373 let config = RendguardConfig {
374 use_global_start_count: 100,
375 use_relay_start_count: 100,
376 ..Default::default()
377 };
378
379 let fp = "A".repeat(40);
380 rg.use_counts.insert(
381 fp.clone(),
382 RendUseCount {
383 idhex: fp.clone(),
384 used: 50.0,
385 weight: 0.001,
386 },
387 );
388 rg.total_use_counts = 1000.0;
389
390 assert!(!rg.is_overused(&fp, &config));
391 }
392
393 #[test]
394 fn test_valid_rend_use_increments_counts() {
395 let mut rg = RendGuard::new();
396 let config = RendguardConfig::default();
397
398 let fp = "A".repeat(40);
399 rg.use_counts.insert(
400 fp.clone(),
401 RendUseCount {
402 idhex: fp.clone(),
403 used: 0.0,
404 weight: 0.1,
405 },
406 );
407
408 rg.valid_rend_use(&fp, &config);
409
410 assert_eq!(rg.use_counts.get(&fp).unwrap().used, 1.0);
411 assert_eq!(rg.total_use_counts, 1.0);
412 }
413
414 #[test]
415 fn test_rend_use_count_creation() {
416 let count = RendUseCount::new("A".repeat(40), 0.05);
417
418 assert_eq!(count.idhex, "A".repeat(40));
419 assert_eq!(count.used, 0.0);
420 assert!((count.weight - 0.05).abs() < 0.001);
421 }
422
423 #[test]
424 fn test_rend_check_result_variants() {
425 let valid = RendCheckResult::Valid;
426 assert_eq!(valid, RendCheckResult::Valid);
427
428 let overused = RendCheckResult::Overused {
429 fingerprint: "A".repeat(40),
430 usage_rate: 10.0,
431 expected_weight: 1.0,
432 };
433
434 match overused {
435 RendCheckResult::Overused {
436 fingerprint,
437 usage_rate,
438 expected_weight,
439 } => {
440 assert_eq!(fingerprint, "A".repeat(40));
441 assert!((usage_rate - 10.0).abs() < 0.001);
442 assert!((expected_weight - 1.0).abs() < 0.001);
443 }
444 _ => panic!("Expected Overused variant"),
445 }
446 }
447}
448
449#[cfg(test)]
450mod proptests {
451 use super::*;
452 use crate::config::RendguardConfig;
453 use proptest::prelude::*;
454 use std::collections::HashMap;
455
456 proptest! {
457 #![proptest_config(ProptestConfig::with_cases(100))]
458
459 #[test]
460 fn rendguard_use_count_tracking(
461 num_relays in 1usize..10,
462 uses_per_relay in prop::collection::vec(1u32..50, 1..10),
463 ) {
464 let mut rg = RendGuard::new();
465 let config = RendguardConfig::default();
466
467 let relays: Vec<String> = (0..num_relays)
468 .map(|i| format!("{:0>40X}", i))
469 .collect();
470
471 for relay in &relays {
472 rg.use_counts.insert(
473 relay.clone(),
474 RendUseCount::new(relay.clone(), 1.0 / num_relays as f64),
475 );
476 }
477
478 let mut expected_uses: HashMap<String, u32> = HashMap::new();
479 let mut total_uses = 0u32;
480
481 for (i, &uses) in uses_per_relay.iter().enumerate() {
482 let relay = &relays[i % num_relays];
483 for _ in 0..uses {
484 rg.valid_rend_use(relay, &config);
485 *expected_uses.entry(relay.clone()).or_insert(0) += 1;
486 total_uses += 1;
487 }
488 }
489
490 for (relay, expected) in &expected_uses {
491 let actual = rg.use_counts.get(relay).map(|c| c.used as u32).unwrap_or(0);
492 prop_assert_eq!(actual, *expected,
493 "Relay {} expected {} uses, got {}", relay, expected, actual);
494 }
495
496 prop_assert!((rg.total_use_counts - total_uses as f64).abs() < 0.001,
497 "Total expected {}, got {}", total_uses, rg.total_use_counts);
498 }
499
500 #[test]
501 fn rendguard_scaling(
502 counts in prop::collection::vec(10.0f64..1000.0, 2..10),
503 ) {
504 let mut rg = RendGuard::new();
505
506 let fingerprints: Vec<String> = (0..counts.len())
507 .map(|i| format!("{:0>40X}", i))
508 .collect();
509
510 for (i, &count) in counts.iter().enumerate() {
511 rg.use_counts.insert(
512 fingerprints[i].clone(),
513 RendUseCount {
514 idhex: fingerprints[i].clone(),
515 used: count,
516 weight: 0.1,
517 },
518 );
519 }
520 rg.total_use_counts = counts.iter().sum();
521
522 let original_total = rg.total_use_counts;
523 let original_counts: HashMap<String, f64> = rg.use_counts.iter()
524 .map(|(k, v)| (k.clone(), v.used))
525 .collect();
526
527 rg.scale_counts();
528
529 for (fp, original) in &original_counts {
530 let scaled = rg.use_counts.get(fp).map(|c| c.used).unwrap_or(0.0);
531 prop_assert!((scaled - original / 2.0).abs() < 0.001,
532 "Count {} expected {}, got {}", fp, original / 2.0, scaled);
533 }
534
535 prop_assert!((rg.total_use_counts - original_total / 2.0).abs() < 0.001,
536 "Total expected {}, got {}", original_total / 2.0, rg.total_use_counts);
537 }
538
539 #[test]
540 fn rendguard_overuse_detection(
541 weight in 0.01f64..0.1,
542 ratio in 2.0f64..10.0,
543 ) {
544 let config = RendguardConfig {
545 use_global_start_count: 1000,
546 use_relay_start_count: 100,
547 use_max_use_to_bw_ratio: ratio,
548 ..Default::default()
549 };
550
551 let mut rg = RendGuard::new();
552 let fp = "A".repeat(40);
553
554 rg.use_counts.insert(
555 fp.clone(),
556 RendUseCount {
557 idhex: fp.clone(),
558 used: 0.0,
559 weight,
560 },
561 );
562
563 let total = 2000.0;
564 let overuse_used = total * weight * ratio * 2.0;
565
566 rg.use_counts.get_mut(&fp).unwrap().used = overuse_used;
567 rg.total_use_counts = total + overuse_used;
568
569 let actual_ratio = overuse_used / rg.total_use_counts;
570 let threshold = weight * ratio;
571
572 if overuse_used >= config.use_relay_start_count as f64
573 && rg.total_use_counts >= config.use_global_start_count as f64
574 && actual_ratio > threshold {
575 prop_assert!(rg.is_overused(&fp, &config),
576 "Relay should be overused: used={}, total={}, actual_ratio={}, threshold={}",
577 overuse_used, rg.total_use_counts, actual_ratio, threshold);
578 }
579
580 let safe_used = total * weight * ratio * 0.3;
581 rg.use_counts.get_mut(&fp).unwrap().used = safe_used.max(config.use_relay_start_count as f64);
582 rg.total_use_counts = total;
583
584 let actual_ratio_safe = rg.use_counts.get(&fp).unwrap().used / rg.total_use_counts;
585
586 if actual_ratio_safe <= threshold {
587 prop_assert!(!rg.is_overused(&fp, &config),
588 "Relay should not be overused: used={}, total={}, actual_ratio={}, threshold={}",
589 rg.use_counts.get(&fp).unwrap().used, rg.total_use_counts, actual_ratio_safe, threshold);
590 }
591 }
592 }
593}