use std::collections::HashMap;
use std::sync::Mutex;
use did_hosting_common::server::auth::session::now_epoch;
use did_hosting_common::server::didcomm_unpack::FRESHNESS_WINDOW_SECS;
use crate::error::AppError;
const MAX_ENTRIES: usize = 50_000;
const EVICT_FRACTION_NUMERATOR: usize = 5;
const EVICT_FRACTION_DENOMINATOR: usize = 100;
#[derive(Debug, Default)]
pub struct ReplayCache {
entries: Mutex<HashMap<(String, String), u64>>,
}
impl ReplayCache {
pub fn new() -> Self {
Self::default()
}
pub fn check_and_insert(&self, sender: &str, msg_id: &str) -> Result<(), AppError> {
let now = now_epoch();
let key = (sender.to_string(), msg_id.to_string());
let mut guard = self
.entries
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(&seen_at) = guard.get(&key)
&& now.saturating_sub(seen_at) <= FRESHNESS_WINDOW_SECS
{
return Err(AppError::Validation(format!(
"duplicate DIDComm message id from {} (replay-detected)",
sender
)));
}
guard.retain(|_, &mut ts| now.saturating_sub(ts) <= FRESHNESS_WINDOW_SECS);
if guard.len() >= MAX_ENTRIES {
let evict_count = MAX_ENTRIES * EVICT_FRACTION_NUMERATOR / EVICT_FRACTION_DENOMINATOR;
let mut by_age: Vec<((String, String), u64)> =
guard.iter().map(|(k, &v)| (k.clone(), v)).collect();
by_age.sort_by_key(|(_, ts)| *ts);
for (k, _) in by_age.into_iter().take(evict_count) {
guard.remove(&k);
}
}
guard.insert(key, now);
Ok(())
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.entries
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.len()
}
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_insert_succeeds() {
let cache = ReplayCache::new();
cache.check_and_insert("did:example:a", "msg-1").unwrap();
assert_eq!(cache.len(), 1);
}
#[test]
fn duplicate_within_window_rejected() {
let cache = ReplayCache::new();
cache.check_and_insert("did:example:a", "msg-1").unwrap();
let err = cache
.check_and_insert("did:example:a", "msg-1")
.unwrap_err();
assert!(matches!(err, AppError::Validation(ref m) if m.contains("replay-detected")));
}
#[test]
fn distinct_sender_or_msg_id_accepted() {
let cache = ReplayCache::new();
cache.check_and_insert("did:example:a", "msg-1").unwrap();
cache.check_and_insert("did:example:a", "msg-2").unwrap();
cache.check_and_insert("did:example:b", "msg-1").unwrap();
assert_eq!(cache.len(), 3);
}
#[test]
fn expired_entry_can_be_re_inserted() {
let cache = ReplayCache::new();
let key = ("did:example:a".to_string(), "msg-1".to_string());
{
let mut guard = cache.entries.lock().unwrap();
guard.insert(
key.clone(),
now_epoch().saturating_sub(FRESHNESS_WINDOW_SECS + 60),
);
}
cache.check_and_insert(&key.0, &key.1).unwrap();
}
#[test]
fn flood_evicts_oldest_to_stay_under_cap() {
let cache = ReplayCache::new();
{
let mut guard = cache.entries.lock().unwrap();
let base = now_epoch();
for i in 0..MAX_ENTRIES {
guard.insert((format!("did:flood:{i}"), "x".to_string()), base + i as u64);
}
}
assert_eq!(cache.len(), MAX_ENTRIES);
cache.check_and_insert("did:flood:fresh", "x").unwrap();
let evict = MAX_ENTRIES * EVICT_FRACTION_NUMERATOR / EVICT_FRACTION_DENOMINATOR;
let expected_after = MAX_ENTRIES - evict + 1; assert_eq!(cache.len(), expected_after);
}
}