Skip to main content

zerodds_security_runtime/
anti_squatter.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! GUID-zu-Identity-Bindings-Cache (C3.8).
5//!
6//! Spec DDS-Security 1.2 §7.4.3 (Identity-Cert-Bind), §9.3.1 + §10.3.4:
7//! ein boeswilliger Peer kann eine fremde GUID schadhaft "squatting"
8//! versuchen, indem er SPDP-Beacons mit gleicher `GuidPrefix` aber
9//! abweichendem IdentityToken sendet. Ohne Bindings-Tracking wuerde
10//! der Peer-Cache den Squatter genauso behandeln wie den legitimen
11//! Original-Peer und im SEDP-Match-Verfahren beginnt eine Confusion-
12//! Phase, die ein DoS-/Cred-Exfil-Vektor ist.
13//!
14//! Der `IdentityBindingCache` hier merkt sich pro `GuidPrefix` den
15//! Hash des **erstmals gesehenen** IdentityToken. Bei nachfolgenden
16//! Announces mit gleicher GuidPrefix wird der Hash neu berechnet und
17//! verglichen — Mismatch fuehrt zur Ablehnung.
18//!
19//! # Was hier nicht gemacht wird
20//!
21//! - **Identity-zu-GUID-Ableitung**: Spec §9.3.1.1 erlaubt eine
22//!   deterministische Bindung GuidPrefix = Hash(Cert-Public-Key). Das
23//!   ist eine staerkere Garantie, kommt aber erst mit dem vollen PKI-
24//!   Handshake-Pfad (C3.1).
25//! - **Time-bounded Re-Binding**: ein Peer der seine Identity-Cert
26//!   rotiert (OCSP-revoked → neues Cert) braucht ein Re-Binding-Path.
27//!   Aktuell muss er aus dem Cache evicted werden, dann re-discovered
28//!   wird er mit der neuen Bindung akzeptiert. Optionale Spec-Sektion
29//!   §10.3.3.2 OCSP — kommt mit C3.9.
30
31use alloc::collections::BTreeMap;
32use alloc::vec::Vec;
33
34/// SHA-256 wuerde es auch tun, aber wir wollen keine zusaetzliche
35/// Crypto-Dep im Cache-Layer. Statt Hash speichern wir den
36/// vollstaendigen Token-Bytes — der ist im Worst-Case ~ 1 KiB pro Peer
37/// und der Cache haelt typisch < 1000 Peers. Bei Ueberlauf trim'd der
38/// Caller (siehe [`IdentityBindingCache::with_capacity`]).
39type IdentityFingerprint = Vec<u8>;
40
41/// 12-byte `GuidPrefix` aus DDSI-RTPS (Wire-Layout).
42pub type GuidPrefixBytes = [u8; 12];
43
44/// Cache der ersten beobachteten Identity-Bindung pro `GuidPrefix`.
45#[derive(Debug, Default)]
46pub struct IdentityBindingCache {
47    bindings: BTreeMap<GuidPrefixBytes, IdentityFingerprint>,
48    capacity: usize,
49}
50
51/// Bewertung eines neuen IdentityToken-Announces durch den Cache.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum BindingDecision {
54    /// Erste Bindung — aufgenommen.
55    NewBinding,
56    /// Bindung existiert und passt — der Peer ist konsistent.
57    Reaffirmed,
58    /// **Konflikt** — gleiche GuidPrefix, abweichendes IdentityToken.
59    /// Der ankommende Announce muss abgelehnt werden.
60    SquatterRejected {
61        /// Bytes des bisher gespeicherten Fingerprints (zum Logging).
62        previous: Vec<u8>,
63    },
64    /// Cache ist voll und der Peer ist neu — Caller entscheidet
65    /// (Eviction-Strategie, sonst Reject).
66    CapacityExhausted,
67}
68
69impl IdentityBindingCache {
70    /// Cache mit unlimitierter Kapazitaet (default fuer Tests).
71    #[must_use]
72    pub fn new() -> Self {
73        Self {
74            bindings: BTreeMap::new(),
75            capacity: usize::MAX,
76        }
77    }
78
79    /// Cache mit explizitem Cap (DoS-Schutz). Bei Erreichen liefert
80    /// [`Self::observe`] `CapacityExhausted` und der Caller muss
81    /// evictieren oder ablehnen.
82    #[must_use]
83    pub fn with_capacity(capacity: usize) -> Self {
84        Self {
85            bindings: BTreeMap::new(),
86            capacity,
87        }
88    }
89
90    /// Aktuelle Anzahl der Bindings.
91    #[must_use]
92    pub fn len(&self) -> usize {
93        self.bindings.len()
94    }
95
96    /// True wenn keine Bindings im Cache sind.
97    #[must_use]
98    pub fn is_empty(&self) -> bool {
99        self.bindings.is_empty()
100    }
101
102    /// Beobachtet eine GuidPrefix-zu-IdentityToken-Bindung.
103    /// `identity_token_bytes` ist der **rohe CDR-DataHolder-Blob** aus
104    /// dem `PID_IDENTITY_TOKEN`-Wert (siehe C3.5).
105    pub fn observe(
106        &mut self,
107        guid_prefix: GuidPrefixBytes,
108        identity_token_bytes: &[u8],
109    ) -> BindingDecision {
110        if let Some(existing) = self.bindings.get(&guid_prefix) {
111            return if existing.as_slice() == identity_token_bytes {
112                BindingDecision::Reaffirmed
113            } else {
114                BindingDecision::SquatterRejected {
115                    previous: existing.clone(),
116                }
117            };
118        }
119        if self.bindings.len() >= self.capacity {
120            return BindingDecision::CapacityExhausted;
121        }
122        self.bindings
123            .insert(guid_prefix, identity_token_bytes.to_vec());
124        BindingDecision::NewBinding
125    }
126
127    /// Entfernt eine Bindung — z.B. wenn ein Peer evicted wurde nach
128    /// Lease-Timeout oder OCSP-Revoke. Erlaubt dem Peer, mit neuer
129    /// Identity zurueck-zu-discoveren.
130    pub fn evict(&mut self, guid_prefix: &GuidPrefixBytes) -> bool {
131        self.bindings.remove(guid_prefix).is_some()
132    }
133
134    /// Liest den aktuellen Fingerprint zu einer GuidPrefix (fuer Audit/
135    /// Logging).
136    #[must_use]
137    pub fn fingerprint_for(&self, guid_prefix: &GuidPrefixBytes) -> Option<&[u8]> {
138        self.bindings.get(guid_prefix).map(Vec::as_slice)
139    }
140}
141
142#[cfg(test)]
143#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
144mod tests {
145    use super::*;
146
147    fn px(byte: u8) -> GuidPrefixBytes {
148        [byte; 12]
149    }
150
151    #[test]
152    fn first_observe_is_new_binding() {
153        let mut c = IdentityBindingCache::new();
154        let d = c.observe(px(0xAA), b"identity-token-A");
155        assert_eq!(d, BindingDecision::NewBinding);
156        assert_eq!(c.len(), 1);
157    }
158
159    #[test]
160    fn second_observe_with_same_token_is_reaffirmed() {
161        let mut c = IdentityBindingCache::new();
162        c.observe(px(0xAA), b"identity-token-A");
163        let d = c.observe(px(0xAA), b"identity-token-A");
164        assert_eq!(d, BindingDecision::Reaffirmed);
165        assert_eq!(c.len(), 1);
166    }
167
168    #[test]
169    fn squatter_with_diff_token_is_rejected() {
170        let mut c = IdentityBindingCache::new();
171        c.observe(px(0xAA), b"identity-token-A");
172        let d = c.observe(px(0xAA), b"identity-token-B");
173        match d {
174            BindingDecision::SquatterRejected { previous } => {
175                assert_eq!(previous, b"identity-token-A");
176            }
177            other => panic!("expected SquatterRejected, got {other:?}"),
178        }
179        // Cache hat den Original-Fingerprint behalten.
180        assert_eq!(c.fingerprint_for(&px(0xAA)), Some(&b"identity-token-A"[..]));
181    }
182
183    #[test]
184    fn distinct_prefixes_are_independent() {
185        let mut c = IdentityBindingCache::new();
186        assert_eq!(
187            c.observe(px(0xAA), b"alice-token"),
188            BindingDecision::NewBinding
189        );
190        assert_eq!(
191            c.observe(px(0xBB), b"bob-token"),
192            BindingDecision::NewBinding
193        );
194        assert_eq!(c.len(), 2);
195    }
196
197    #[test]
198    fn evict_allows_rebinding_with_new_token() {
199        let mut c = IdentityBindingCache::new();
200        c.observe(px(0xAA), b"old-token");
201        assert!(c.evict(&px(0xAA)));
202        let d = c.observe(px(0xAA), b"new-token");
203        assert_eq!(d, BindingDecision::NewBinding);
204    }
205
206    #[test]
207    fn evict_unknown_prefix_returns_false() {
208        let mut c = IdentityBindingCache::new();
209        assert!(!c.evict(&px(0xCC)));
210    }
211
212    #[test]
213    fn capacity_cap_rejects_new_bindings() {
214        let mut c = IdentityBindingCache::with_capacity(2);
215        assert_eq!(c.observe(px(0x01), b"a"), BindingDecision::NewBinding);
216        assert_eq!(c.observe(px(0x02), b"b"), BindingDecision::NewBinding);
217        assert_eq!(
218            c.observe(px(0x03), b"c"),
219            BindingDecision::CapacityExhausted
220        );
221        assert_eq!(c.len(), 2);
222    }
223
224    #[test]
225    fn capacity_cap_still_allows_reaffirm() {
226        let mut c = IdentityBindingCache::with_capacity(1);
227        c.observe(px(0x01), b"a");
228        // Cache ist voll, aber re-affirm eines bekannten Peers darf
229        // weiter funktionieren.
230        assert_eq!(c.observe(px(0x01), b"a"), BindingDecision::Reaffirmed);
231    }
232
233    #[test]
234    fn capacity_cap_still_detects_squatter() {
235        let mut c = IdentityBindingCache::with_capacity(1);
236        c.observe(px(0x01), b"a");
237        // Cache ist voll. Bekannter Prefix mit anderem Token ist
238        // weiterhin Squatter — nicht "CapacityExhausted".
239        match c.observe(px(0x01), b"b") {
240            BindingDecision::SquatterRejected { .. } => {}
241            other => panic!("expected SquatterRejected, got {other:?}"),
242        }
243    }
244
245    #[test]
246    fn empty_token_is_distinct_from_some_token() {
247        let mut c = IdentityBindingCache::new();
248        c.observe(px(0x01), b"");
249        let d = c.observe(px(0x01), b"non-empty");
250        assert!(matches!(d, BindingDecision::SquatterRejected { .. }));
251    }
252}