Skip to main content

ruvix_types/
proof_cache.rs

1//! Proof cache with security constraints (ADR-087 SEC-002).
2//!
3//! This module implements a secure proof cache with the following constraints:
4//!
5//! - **100ms TTL**: Cache entries expire after 100ms to prevent replay attacks
6//! - **Single-use nonces**: Each (mutation_hash, nonce) pair can only be verified once
7//! - **Maximum 64 entries**: Bounded cache size to prevent memory exhaustion
8//! - **Scoped entries**: Entries are keyed by (mutation_hash, nonce) pairs
9//!
10//! # Security Rationale
11//!
12//! The proof cache is used for Reflex-tier proofs (sub-microsecond verification).
13//! Without proper bounds:
14//! - TTL prevents replay of old proofs
15//! - Single-use nonces prevent double-verification attacks
16//! - Entry limit prevents denial-of-service via cache flooding
17//!
18//! # Example
19//!
20//! ```
21//! use ruvix_types::{ProofCache, CacheError};
22//!
23//! let mut cache = ProofCache::new();
24//!
25//! let mutation_hash = [0u8; 32];
26//! let nonce = 12345u64;
27//! let proof_id = 1u32;
28//! let current_time_ns = 1_000_000u64; // 1ms
29//!
30//! // Insert a proof
31//! cache.insert(mutation_hash, nonce, proof_id, current_time_ns).unwrap();
32//!
33//! // Verify and consume (single-use)
34//! let id = cache.verify_and_consume(&mutation_hash, nonce, current_time_ns).unwrap();
35//! assert_eq!(id, proof_id);
36//!
37//! // Second verification fails (nonce consumed)
38//! assert!(cache.verify_and_consume(&mutation_hash, nonce, current_time_ns).is_err());
39//! ```
40
41/// Maximum number of entries in the proof cache (SEC-002).
42pub const PROOF_CACHE_MAX_ENTRIES: usize = 64;
43
44/// TTL for cache entries in nanoseconds (100ms = 100_000_000ns).
45pub const PROOF_CACHE_TTL_NS: u64 = 100_000_000;
46
47/// TTL for cache entries in milliseconds (100ms).
48pub const PROOF_CACHE_TTL_MS: u32 = 100;
49
50/// Error types for proof cache operations.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum CacheError {
53    /// Cache is full (64 entries maximum).
54    CacheFull,
55    /// Entry not found for the given (mutation_hash, nonce).
56    NotFound,
57    /// Entry has expired (TTL exceeded).
58    Expired,
59    /// Nonce has already been consumed (single-use violation).
60    NonceConsumed,
61    /// Duplicate entry (same mutation_hash + nonce already exists).
62    DuplicateEntry,
63}
64
65/// A single entry in the proof cache.
66///
67/// Each entry is scoped to a (mutation_hash, nonce) pair and includes
68/// TTL information for expiry tracking.
69#[derive(Debug, Clone, Copy)]
70#[repr(C)]
71pub struct ProofCacheEntry {
72    /// Unique proof identifier returned on successful verification.
73    pub proof_id: u32,
74    /// Timestamp when the entry was inserted (nanoseconds).
75    pub inserted_at: u64,
76    /// Single-use nonce for this proof.
77    pub nonce: u64,
78    /// Hash of the mutation this proof authorizes.
79    pub mutation_hash: [u8; 32],
80    /// Whether this entry has been consumed (single-use).
81    pub consumed: bool,
82}
83
84impl ProofCacheEntry {
85    /// Creates a new cache entry.
86    #[must_use]
87    pub const fn new(
88        proof_id: u32,
89        inserted_at: u64,
90        nonce: u64,
91        mutation_hash: [u8; 32],
92    ) -> Self {
93        Self {
94            proof_id,
95            inserted_at,
96            nonce,
97            mutation_hash,
98            consumed: false,
99        }
100    }
101
102    /// Checks if the entry has expired.
103    #[must_use]
104    #[inline]
105    pub const fn is_expired(&self, current_time_ns: u64) -> bool {
106        // Handle potential overflow: if current_time is before inserted_at,
107        // the entry is definitely not expired (clock rollback scenario)
108        if current_time_ns < self.inserted_at {
109            return false;
110        }
111        current_time_ns - self.inserted_at > PROOF_CACHE_TTL_NS
112    }
113
114    /// Checks if the entry matches the given mutation_hash and nonce.
115    #[must_use]
116    #[inline]
117    pub fn matches(&self, mutation_hash: &[u8; 32], nonce: u64) -> bool {
118        self.nonce == nonce && self.mutation_hash == *mutation_hash
119    }
120}
121
122/// Secure proof cache with TTL, single-use nonces, and bounded size.
123///
124/// Implements SEC-002 security requirements:
125/// - 100ms TTL for cache entries
126/// - Single-use nonce consumption
127/// - Maximum 64 entries
128/// - Entries scoped to (mutation_hash, nonce) pairs
129#[derive(Debug)]
130pub struct ProofCache {
131    /// Fixed-size array of cache entries.
132    entries: [Option<ProofCacheEntry>; PROOF_CACHE_MAX_ENTRIES],
133    /// Number of active (non-None) entries.
134    count: usize,
135}
136
137impl ProofCache {
138    /// Creates a new empty proof cache.
139    #[must_use]
140    pub const fn new() -> Self {
141        const NONE: Option<ProofCacheEntry> = None;
142        Self {
143            entries: [NONE; PROOF_CACHE_MAX_ENTRIES],
144            count: 0,
145        }
146    }
147
148    /// Returns the number of active entries in the cache.
149    #[must_use]
150    #[inline]
151    pub const fn len(&self) -> usize {
152        self.count
153    }
154
155    /// Returns true if the cache is empty.
156    #[must_use]
157    #[inline]
158    pub const fn is_empty(&self) -> bool {
159        self.count == 0
160    }
161
162    /// Returns true if the cache is full (64 entries).
163    #[must_use]
164    #[inline]
165    pub const fn is_full(&self) -> bool {
166        self.count >= PROOF_CACHE_MAX_ENTRIES
167    }
168
169    /// Inserts a new proof into the cache.
170    ///
171    /// # Arguments
172    ///
173    /// * `mutation_hash` - Hash of the mutation being authorized
174    /// * `nonce` - Single-use nonce for this proof
175    /// * `proof_id` - Unique identifier for this proof
176    /// * `current_time_ns` - Current time in nanoseconds
177    ///
178    /// # Errors
179    ///
180    /// - `CacheError::DuplicateEntry` if an entry with the same (mutation_hash, nonce) exists
181    /// - `CacheError::CacheFull` if the cache is at capacity and no expired entries can be evicted
182    #[must_use]
183    pub fn insert(
184        &mut self,
185        mutation_hash: [u8; 32],
186        nonce: u64,
187        proof_id: u32,
188        current_time_ns: u64,
189    ) -> Result<(), CacheError> {
190        // First pass: check for duplicates and evict expired entries
191        for i in 0..PROOF_CACHE_MAX_ENTRIES {
192            if let Some(ref entry) = self.entries[i] {
193                // Check for duplicate
194                if entry.matches(&mutation_hash, nonce) && !entry.consumed {
195                    return Err(CacheError::DuplicateEntry);
196                }
197
198                // Evict expired or consumed entries
199                if entry.is_expired(current_time_ns) || entry.consumed {
200                    self.entries[i] = None;
201                    self.count = self.count.saturating_sub(1);
202                }
203            }
204        }
205
206        // Find an empty slot
207        let mut slot = None;
208        for i in 0..PROOF_CACHE_MAX_ENTRIES {
209            if self.entries[i].is_none() {
210                slot = Some(i);
211                break;
212            }
213        }
214
215        match slot {
216            Some(i) => {
217                self.entries[i] = Some(ProofCacheEntry::new(
218                    proof_id,
219                    current_time_ns,
220                    nonce,
221                    mutation_hash,
222                ));
223                self.count += 1;
224                Ok(())
225            }
226            None => Err(CacheError::CacheFull),
227        }
228    }
229
230    /// Verifies and consumes a proof from the cache.
231    ///
232    /// This is the primary security-critical function. It:
233    /// 1. Finds the entry matching (mutation_hash, nonce)
234    /// 2. Checks that TTL has not expired
235    /// 3. Marks the entry as consumed (single-use)
236    /// 4. Removes the entry from the cache
237    /// 5. Returns the proof_id
238    ///
239    /// # Arguments
240    ///
241    /// * `mutation_hash` - Hash of the mutation being verified
242    /// * `nonce` - Nonce that was used when the proof was created
243    /// * `current_time_ns` - Current time in nanoseconds
244    ///
245    /// # Returns
246    ///
247    /// The proof_id if verification succeeds.
248    ///
249    /// # Errors
250    ///
251    /// - `CacheError::NotFound` if no matching entry exists
252    /// - `CacheError::Expired` if the entry's TTL has passed
253    /// - `CacheError::NonceConsumed` if the nonce was already used
254    #[must_use]
255    pub fn verify_and_consume(
256        &mut self,
257        mutation_hash: &[u8; 32],
258        nonce: u64,
259        current_time_ns: u64,
260    ) -> Result<u32, CacheError> {
261        // Find matching entry
262        for i in 0..PROOF_CACHE_MAX_ENTRIES {
263            if let Some(ref mut entry) = self.entries[i] {
264                if entry.matches(mutation_hash, nonce) {
265                    // Check if already consumed
266                    if entry.consumed {
267                        // Remove the consumed entry
268                        self.entries[i] = None;
269                        self.count = self.count.saturating_sub(1);
270                        return Err(CacheError::NonceConsumed);
271                    }
272
273                    // Check TTL
274                    if entry.is_expired(current_time_ns) {
275                        // Remove the expired entry
276                        self.entries[i] = None;
277                        self.count = self.count.saturating_sub(1);
278                        return Err(CacheError::Expired);
279                    }
280
281                    // Mark as consumed and remove
282                    let proof_id = entry.proof_id;
283                    self.entries[i] = None;
284                    self.count = self.count.saturating_sub(1);
285
286                    return Ok(proof_id);
287                }
288            }
289        }
290
291        Err(CacheError::NotFound)
292    }
293
294    /// Checks if a proof exists in the cache without consuming it.
295    ///
296    /// This is useful for pre-validation before attempting a mutation.
297    /// Note: This does NOT consume the nonce.
298    #[must_use]
299    pub fn exists(&self, mutation_hash: &[u8; 32], nonce: u64, current_time_ns: u64) -> bool {
300        for entry in &self.entries {
301            if let Some(ref e) = entry {
302                if e.matches(mutation_hash, nonce) && !e.consumed && !e.is_expired(current_time_ns)
303                {
304                    return true;
305                }
306            }
307        }
308        false
309    }
310
311    /// Removes all expired entries from the cache.
312    ///
313    /// This can be called periodically to clean up the cache.
314    pub fn evict_expired(&mut self, current_time_ns: u64) {
315        for i in 0..PROOF_CACHE_MAX_ENTRIES {
316            if let Some(ref entry) = self.entries[i] {
317                if entry.is_expired(current_time_ns) || entry.consumed {
318                    self.entries[i] = None;
319                    self.count = self.count.saturating_sub(1);
320                }
321            }
322        }
323    }
324
325    /// Clears all entries from the cache.
326    pub fn clear(&mut self) {
327        for i in 0..PROOF_CACHE_MAX_ENTRIES {
328            self.entries[i] = None;
329        }
330        self.count = 0;
331    }
332
333    /// Returns statistics about the cache.
334    #[must_use]
335    pub fn stats(&self, current_time_ns: u64) -> ProofCacheStats {
336        let mut active = 0;
337        let mut expired = 0;
338        let mut consumed = 0;
339
340        for entry in &self.entries {
341            if let Some(ref e) = entry {
342                if e.consumed {
343                    consumed += 1;
344                } else if e.is_expired(current_time_ns) {
345                    expired += 1;
346                } else {
347                    active += 1;
348                }
349            }
350        }
351
352        ProofCacheStats {
353            total_slots: PROOF_CACHE_MAX_ENTRIES,
354            active_entries: active,
355            expired_entries: expired,
356            consumed_entries: consumed,
357            free_slots: PROOF_CACHE_MAX_ENTRIES - (active + expired + consumed),
358        }
359    }
360}
361
362impl Default for ProofCache {
363    fn default() -> Self {
364        Self::new()
365    }
366}
367
368/// Statistics about the proof cache.
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370pub struct ProofCacheStats {
371    /// Total number of slots (always 64).
372    pub total_slots: usize,
373    /// Number of active (valid, unconsumed) entries.
374    pub active_entries: usize,
375    /// Number of expired entries awaiting cleanup.
376    pub expired_entries: usize,
377    /// Number of consumed entries awaiting cleanup.
378    pub consumed_entries: usize,
379    /// Number of free slots.
380    pub free_slots: usize,
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn test_cache_insert_and_verify() {
389        let mut cache = ProofCache::new();
390
391        let mutation_hash = [42u8; 32];
392        let nonce = 12345u64;
393        let proof_id = 1u32;
394        let time = 1_000_000u64;
395
396        // Insert
397        assert!(cache.insert(mutation_hash, nonce, proof_id, time).is_ok());
398        assert_eq!(cache.len(), 1);
399
400        // Verify exists
401        assert!(cache.exists(&mutation_hash, nonce, time));
402
403        // Verify and consume
404        let result = cache.verify_and_consume(&mutation_hash, nonce, time);
405        assert_eq!(result, Ok(proof_id));
406        assert_eq!(cache.len(), 0);
407
408        // Second verification should fail (consumed)
409        let result = cache.verify_and_consume(&mutation_hash, nonce, time);
410        assert_eq!(result, Err(CacheError::NotFound));
411    }
412
413    #[test]
414    fn test_nonce_single_use() {
415        let mut cache = ProofCache::new();
416
417        let mutation_hash = [1u8; 32];
418        let nonce = 99999u64;
419        let time = 0u64;
420
421        cache.insert(mutation_hash, nonce, 1, time).unwrap();
422
423        // First verification succeeds
424        assert!(cache.verify_and_consume(&mutation_hash, nonce, time).is_ok());
425
426        // Insert again with same nonce (should succeed since old entry was removed)
427        cache.insert(mutation_hash, nonce, 2, time).unwrap();
428
429        // Second verification should succeed (new entry)
430        assert!(cache.verify_and_consume(&mutation_hash, nonce, time).is_ok());
431    }
432
433    #[test]
434    fn test_ttl_expiry() {
435        let mut cache = ProofCache::new();
436
437        let mutation_hash = [2u8; 32];
438        let nonce = 1u64;
439        let proof_id = 10u32;
440        let insert_time = 1_000_000u64;
441
442        cache.insert(mutation_hash, nonce, proof_id, insert_time).unwrap();
443
444        // Verify within TTL (50ms later)
445        let time_within_ttl = insert_time + 50_000_000;
446        assert!(cache.exists(&mutation_hash, nonce, time_within_ttl));
447
448        // Verify after TTL (150ms later)
449        let time_after_ttl = insert_time + 150_000_000;
450        assert!(!cache.exists(&mutation_hash, nonce, time_after_ttl));
451
452        // verify_and_consume should return Expired
453        let result = cache.verify_and_consume(&mutation_hash, nonce, time_after_ttl);
454        assert_eq!(result, Err(CacheError::Expired));
455    }
456
457    #[test]
458    fn test_max_entries() {
459        let mut cache = ProofCache::new();
460
461        // Fill the cache
462        for i in 0..PROOF_CACHE_MAX_ENTRIES {
463            let mut hash = [0u8; 32];
464            hash[0] = i as u8;
465            cache.insert(hash, i as u64, i as u32, 0).unwrap();
466        }
467
468        assert!(cache.is_full());
469        assert_eq!(cache.len(), PROOF_CACHE_MAX_ENTRIES);
470
471        // Try to insert one more (should fail)
472        let result = cache.insert([255u8; 32], 999, 999, 0);
473        assert_eq!(result, Err(CacheError::CacheFull));
474    }
475
476    #[test]
477    fn test_eviction_of_expired() {
478        let mut cache = ProofCache::new();
479
480        // Fill cache with entries
481        for i in 0..PROOF_CACHE_MAX_ENTRIES {
482            let mut hash = [0u8; 32];
483            hash[0] = i as u8;
484            cache.insert(hash, i as u64, i as u32, 0).unwrap();
485        }
486
487        assert!(cache.is_full());
488
489        // Try to insert with time past TTL (should succeed by evicting expired)
490        let later = PROOF_CACHE_TTL_NS + 1;
491        let result = cache.insert([254u8; 32], 9999, 9999, later);
492        assert!(result.is_ok());
493    }
494
495    #[test]
496    fn test_duplicate_entry() {
497        let mut cache = ProofCache::new();
498
499        let hash = [5u8; 32];
500        let nonce = 100u64;
501
502        cache.insert(hash, nonce, 1, 0).unwrap();
503
504        // Try to insert duplicate
505        let result = cache.insert(hash, nonce, 2, 0);
506        assert_eq!(result, Err(CacheError::DuplicateEntry));
507    }
508
509    #[test]
510    fn test_stats() {
511        let mut cache = ProofCache::new();
512
513        // Insert some entries
514        for i in 0..10 {
515            let mut hash = [0u8; 32];
516            hash[0] = i as u8;
517            cache.insert(hash, i as u64, i as u32, 0).unwrap();
518        }
519
520        let stats = cache.stats(0);
521        assert_eq!(stats.active_entries, 10);
522        assert_eq!(stats.free_slots, PROOF_CACHE_MAX_ENTRIES - 10);
523
524        // Consume some
525        let mut hash = [0u8; 32];
526        cache.verify_and_consume(&hash, 0, 0).unwrap();
527        hash[0] = 1;
528        cache.verify_and_consume(&hash, 1, 0).unwrap();
529
530        let stats = cache.stats(0);
531        assert_eq!(stats.active_entries, 8);
532
533        // Check expired entries
534        let later = PROOF_CACHE_TTL_NS + 1;
535        let stats = cache.stats(later);
536        assert_eq!(stats.expired_entries, 8);
537        assert_eq!(stats.active_entries, 0);
538    }
539
540    #[test]
541    fn test_evict_expired() {
542        let mut cache = ProofCache::new();
543
544        for i in 0..5 {
545            let mut hash = [0u8; 32];
546            hash[0] = i as u8;
547            cache.insert(hash, i as u64, i as u32, 0).unwrap();
548        }
549
550        assert_eq!(cache.len(), 5);
551
552        // Evict at time past TTL
553        cache.evict_expired(PROOF_CACHE_TTL_NS + 1);
554        assert_eq!(cache.len(), 0);
555    }
556
557    #[test]
558    fn test_clear() {
559        let mut cache = ProofCache::new();
560
561        for i in 0..10 {
562            let mut hash = [0u8; 32];
563            hash[0] = i as u8;
564            cache.insert(hash, i as u64, i as u32, 0).unwrap();
565        }
566
567        assert_eq!(cache.len(), 10);
568        cache.clear();
569        assert_eq!(cache.len(), 0);
570        assert!(cache.is_empty());
571    }
572
573    #[test]
574    fn test_entry_expired() {
575        let entry = ProofCacheEntry::new(1, 0, 0, [0u8; 32]);
576
577        // Not expired at insert time
578        assert!(!entry.is_expired(0));
579
580        // Not expired 50ms later
581        assert!(!entry.is_expired(50_000_000));
582
583        // Not expired at exactly TTL
584        assert!(!entry.is_expired(PROOF_CACHE_TTL_NS));
585
586        // Expired 1ns after TTL
587        assert!(entry.is_expired(PROOF_CACHE_TTL_NS + 1));
588
589        // Handle clock rollback (time before insert)
590        assert!(!entry.is_expired(0));
591    }
592}