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}