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-to-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//! a malicious peer can attempt to maliciously "squat" a foreign GUID
8//! by sending SPDP beacons with the same `GuidPrefix` but a
9//! differing IdentityToken. Without bindings tracking the
10//! peer cache would treat the squatter exactly like the legitimate
11//! original peer, and in the SEDP match procedure a confusion
12//! phase begins which is a DoS / cred-exfil vector.
13//!
14//! The `IdentityBindingCache` here remembers, per `GuidPrefix`, the
15//! hash of the **first-seen** IdentityToken. On subsequent
16//! announces with the same GuidPrefix the hash is recomputed and
17//! compared — a mismatch leads to rejection.
18//!
19//! # What is not done here
20//!
21//! - **Identity-to-GUID derivation**: spec §9.3.1.1 allows a
22//!   deterministic binding GuidPrefix = Hash(cert public key). That
23//!   is a stronger guarantee but only arrives with the full PKI
24//!   handshake path (C3.1).
25//! - **Time-bounded re-binding**: a peer that rotates its identity cert
26//!   (OCSP-revoked → new cert) needs a re-binding path.
27//!   Currently it must be evicted from the cache, then once re-discovered
28//!   it is accepted with the new binding. Optional spec section
29//!   §10.3.3.2 OCSP — arrives with C3.9.
30
31use alloc::collections::BTreeMap;
32use alloc::vec::Vec;
33
34/// SHA-256 would also do, but we don't want an additional
35/// crypto dep in the cache layer. Instead of a hash we store the
36/// full token bytes — worst case ~ 1 KiB per peer
37/// and the cache typically holds < 1000 peers. On overflow the
38/// caller trims (see [`IdentityBindingCache::with_capacity`]).
39type IdentityFingerprint = Vec<u8>;
40
41/// 12-byte `GuidPrefix` from DDSI-RTPS (wire layout).
42pub type GuidPrefixBytes = [u8; 12];
43
44/// Cache of the first observed identity binding per `GuidPrefix`.
45#[derive(Debug, Default)]
46pub struct IdentityBindingCache {
47    bindings: BTreeMap<GuidPrefixBytes, IdentityFingerprint>,
48    capacity: usize,
49}
50
51/// Evaluation of a new IdentityToken announce by the cache.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum BindingDecision {
54    /// First binding — recorded.
55    NewBinding,
56    /// The binding exists and matches — the peer is consistent.
57    Reaffirmed,
58    /// **Conflict** — same GuidPrefix, differing IdentityToken.
59    /// The incoming announce must be rejected.
60    SquatterRejected {
61        /// Bytes of the previously stored fingerprint (for logging).
62        previous: Vec<u8>,
63    },
64    /// The cache is full and the peer is new — the caller decides
65    /// (eviction strategy, otherwise reject).
66    CapacityExhausted,
67}
68
69impl IdentityBindingCache {
70    /// Cache with unlimited capacity (default for tests).
71    #[must_use]
72    pub fn new() -> Self {
73        Self {
74            bindings: BTreeMap::new(),
75            capacity: usize::MAX,
76        }
77    }
78
79    /// Cache with an explicit cap (DoS protection). On reaching it,
80    /// [`Self::observe`] returns `CapacityExhausted` and the caller must
81    /// evict or reject.
82    #[must_use]
83    pub fn with_capacity(capacity: usize) -> Self {
84        Self {
85            bindings: BTreeMap::new(),
86            capacity,
87        }
88    }
89
90    /// Current number of bindings.
91    #[must_use]
92    pub fn len(&self) -> usize {
93        self.bindings.len()
94    }
95
96    /// True if there are no bindings in the cache.
97    #[must_use]
98    pub fn is_empty(&self) -> bool {
99        self.bindings.is_empty()
100    }
101
102    /// Observes a GuidPrefix-to-IdentityToken binding.
103    /// `identity_token_bytes` is the **raw CDR DataHolder blob** from
104    /// the `PID_IDENTITY_TOKEN` value (see 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    /// Removes a binding — e.g. when a peer was evicted after a
128    /// lease timeout or OCSP revoke. Allows the peer to re-discover
129    /// with a new identity.
130    pub fn evict(&mut self, guid_prefix: &GuidPrefixBytes) -> bool {
131        self.bindings.remove(guid_prefix).is_some()
132    }
133
134    /// Reads the current fingerprint for a GuidPrefix (for 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        // The cache kept the original fingerprint.
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        // The cache is full, but re-affirming a known peer must
229        // keep working.
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        // The cache is full. A known prefix with a different token is
238        // still a squatter — not "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}