Skip to main content

fips_core/utils/
index.rs

1//! Session Index Allocator
2//!
3//! Manages allocation of 32-bit session indices for O(1) packet dispatch.
4//! Each Noise session receives a unique index chosen by the receiver;
5//! incoming encrypted packets include the receiver's index for fast lookup.
6//!
7//! ## Design
8//!
9//! - Indices are random (cryptographically secure) to prevent guessing
10//! - Unique per transport to avoid collision between transports
11//! - 32-bit space supports ~65K concurrent sessions before birthday collision
12//! - Indices are rotated on rekey for anti-correlation
13//!
14//! ## Wire Format
15//!
16//! Encrypted frames include receiver_idx for session lookup:
17//!
18//! ```text
19//! [0x00][receiver_idx:4 LE][counter:8 LE][ciphertext+tag]
20//! ```
21
22use rand::RngExt;
23use std::collections::HashSet;
24use thiserror::Error;
25
26/// Errors related to index allocation.
27#[derive(Debug, Error)]
28pub enum IndexError {
29    #[error("no available indices (too many active sessions)")]
30    Exhausted,
31
32    #[error("index {0} not found")]
33    NotFound(u32),
34
35    #[error("index {0} already in use")]
36    AlreadyInUse(u32),
37}
38
39/// A 32-bit session index.
40///
41/// Wrapper type for type safety and clarity in APIs.
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
43pub struct SessionIndex(u32);
44
45impl SessionIndex {
46    /// Create from raw u32.
47    pub fn new(value: u32) -> Self {
48        Self(value)
49    }
50
51    /// Get the raw u32 value.
52    pub fn as_u32(&self) -> u32 {
53        self.0
54    }
55
56    /// Convert to little-endian bytes.
57    pub fn to_le_bytes(&self) -> [u8; 4] {
58        self.0.to_le_bytes()
59    }
60
61    /// Create from little-endian bytes.
62    pub fn from_le_bytes(bytes: [u8; 4]) -> Self {
63        Self(u32::from_le_bytes(bytes))
64    }
65}
66
67impl std::fmt::Display for SessionIndex {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        write!(f, "{:08x}", self.0)
70    }
71}
72
73/// Allocator for session indices within a single transport.
74///
75/// Manages a pool of random 32-bit indices, tracking which are in use
76/// to prevent collision. Thread-safe for single-threaded async use.
77#[derive(Debug)]
78pub struct IndexAllocator {
79    /// Set of currently allocated indices.
80    in_use: HashSet<u32>,
81    /// Maximum allocation attempts before giving up.
82    max_attempts: usize,
83}
84
85impl IndexAllocator {
86    /// Create a new index allocator.
87    pub fn new() -> Self {
88        Self {
89            in_use: HashSet::new(),
90            max_attempts: 100,
91        }
92    }
93
94    /// Create with a specific max attempts limit.
95    pub fn with_max_attempts(max_attempts: usize) -> Self {
96        Self {
97            in_use: HashSet::new(),
98            max_attempts,
99        }
100    }
101
102    /// Allocate a new random index.
103    ///
104    /// Returns a cryptographically random 32-bit index that is not
105    /// currently in use. Returns error if allocation fails after
106    /// max_attempts tries (indicates too many active sessions).
107    pub fn allocate(&mut self) -> Result<SessionIndex, IndexError> {
108        let mut rng = rand::rng();
109
110        for _ in 0..self.max_attempts {
111            let candidate = rng.random::<u32>();
112            if !self.in_use.contains(&candidate) {
113                self.in_use.insert(candidate);
114                return Ok(SessionIndex(candidate));
115            }
116        }
117
118        Err(IndexError::Exhausted)
119    }
120
121    /// Free an index, returning it to the available pool.
122    ///
123    /// Returns error if the index was not allocated.
124    pub fn free(&mut self, index: SessionIndex) -> Result<(), IndexError> {
125        if self.in_use.remove(&index.0) {
126            Ok(())
127        } else {
128            Err(IndexError::NotFound(index.0))
129        }
130    }
131
132    /// Check if an index is currently allocated.
133    pub fn is_allocated(&self, index: SessionIndex) -> bool {
134        self.in_use.contains(&index.0)
135    }
136
137    /// Number of currently allocated indices.
138    pub fn count(&self) -> usize {
139        self.in_use.len()
140    }
141
142    /// Check if the allocator is empty (no indices allocated).
143    pub fn is_empty(&self) -> bool {
144        self.in_use.is_empty()
145    }
146
147    /// Reserve a specific index (for testing or migration).
148    ///
149    /// Returns error if the index is already in use.
150    pub fn reserve(&mut self, index: SessionIndex) -> Result<(), IndexError> {
151        if self.in_use.contains(&index.0) {
152            Err(IndexError::AlreadyInUse(index.0))
153        } else {
154            self.in_use.insert(index.0);
155            Ok(())
156        }
157    }
158
159    /// Clear all allocations (use with caution).
160    pub fn clear(&mut self) {
161        self.in_use.clear();
162    }
163}
164
165impl Default for IndexAllocator {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_session_index_roundtrip() {
177        let idx = SessionIndex::new(0x12345678);
178        assert_eq!(idx.as_u32(), 0x12345678);
179
180        let bytes = idx.to_le_bytes();
181        assert_eq!(bytes, [0x78, 0x56, 0x34, 0x12]);
182
183        let restored = SessionIndex::from_le_bytes(bytes);
184        assert_eq!(restored, idx);
185    }
186
187    #[test]
188    fn test_session_index_display() {
189        let idx = SessionIndex::new(0x000000ff);
190        assert_eq!(format!("{}", idx), "000000ff");
191
192        let idx = SessionIndex::new(0xdeadbeef);
193        assert_eq!(format!("{}", idx), "deadbeef");
194    }
195
196    #[test]
197    fn test_allocator_basic() {
198        let mut alloc = IndexAllocator::new();
199        assert!(alloc.is_empty());
200        assert_eq!(alloc.count(), 0);
201
202        let idx1 = alloc.allocate().unwrap();
203        assert!(!alloc.is_empty());
204        assert_eq!(alloc.count(), 1);
205        assert!(alloc.is_allocated(idx1));
206
207        let idx2 = alloc.allocate().unwrap();
208        assert_eq!(alloc.count(), 2);
209        assert!(alloc.is_allocated(idx2));
210        assert_ne!(idx1, idx2);
211
212        alloc.free(idx1).unwrap();
213        assert_eq!(alloc.count(), 1);
214        assert!(!alloc.is_allocated(idx1));
215        assert!(alloc.is_allocated(idx2));
216    }
217
218    #[test]
219    fn test_allocator_free_not_found() {
220        let mut alloc = IndexAllocator::new();
221        let result = alloc.free(SessionIndex::new(12345));
222        assert!(matches!(result, Err(IndexError::NotFound(12345))));
223    }
224
225    #[test]
226    fn test_allocator_reserve() {
227        let mut alloc = IndexAllocator::new();
228
229        let idx = SessionIndex::new(0xdeadbeef);
230        alloc.reserve(idx).unwrap();
231        assert!(alloc.is_allocated(idx));
232
233        // Double reserve fails
234        let result = alloc.reserve(idx);
235        assert!(matches!(result, Err(IndexError::AlreadyInUse(0xdeadbeef))));
236    }
237
238    #[test]
239    fn test_allocator_uniqueness() {
240        let mut alloc = IndexAllocator::new();
241        let mut indices = Vec::new();
242
243        // Allocate 1000 indices and verify all unique
244        for _ in 0..1000 {
245            let idx = alloc.allocate().unwrap();
246            assert!(!indices.contains(&idx));
247            indices.push(idx);
248        }
249
250        assert_eq!(alloc.count(), 1000);
251    }
252
253    #[test]
254    fn test_allocator_clear() {
255        let mut alloc = IndexAllocator::new();
256
257        for _ in 0..10 {
258            alloc.allocate().unwrap();
259        }
260        assert_eq!(alloc.count(), 10);
261
262        alloc.clear();
263        assert!(alloc.is_empty());
264        assert_eq!(alloc.count(), 0);
265    }
266
267    #[test]
268    fn test_allocator_reuse_after_free() {
269        let mut alloc = IndexAllocator::new();
270
271        let idx = alloc.allocate().unwrap();
272        alloc.free(idx).unwrap();
273
274        // The specific index might not be reused immediately (random),
275        // but the count should allow allocation
276        let idx2 = alloc.allocate().unwrap();
277        assert_eq!(alloc.count(), 1);
278
279        // Can now reserve the original index if it wasn't randomly reused
280        if idx != idx2 {
281            alloc.reserve(idx).unwrap();
282        }
283    }
284}