use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use dashmap::DashMap;
use keyhog_core::{sha256_hash, CredentialHash, VerificationResult};
use sha2::{Digest, Sha256};
pub(crate) struct VerificationCache {
entries: DashMap<VerificationIdentity, CacheEntry>,
inserts: AtomicUsize,
max_entries: usize,
ttl: Duration,
generation: AtomicU64,
queue: parking_lot::Mutex<std::collections::VecDeque<(VerificationIdentity, u64)>>,
}
#[derive(Hash, Eq, PartialEq, Clone)]
pub(crate) struct VerificationIdentity {
credential_hash: CredentialHash,
detector_id_hash: CredentialHash,
companions_hash: CredentialHash,
}
struct CacheEntry {
result: VerificationResult,
metadata: HashMap<String, String>,
expires_at: Instant,
generation: u64,
}
impl VerificationCache {
const DEFAULT_TTL_SECS: u64 = 300;
const DEFAULT_MAX_ENTRIES: usize = 10_000;
const EVICTION_INTERVAL: usize = 64;
const MAX_METADATA_ENTRIES: usize = 16;
const MAX_METADATA_KEY_BYTES: usize = 64;
const MAX_METADATA_VALUE_BYTES: usize = 256;
pub(crate) fn new(ttl: Duration) -> Self {
Self::with_max_entries(ttl, Self::DEFAULT_MAX_ENTRIES)
}
pub(crate) fn with_max_entries(ttl: Duration, max_entries: usize) -> Self {
Self {
entries: DashMap::new(),
inserts: AtomicUsize::new(0),
max_entries: max_entries.max(1),
ttl,
generation: AtomicU64::new(0),
queue: parking_lot::Mutex::new(std::collections::VecDeque::new()),
}
}
pub(crate) fn default_ttl() -> Self {
Self::new(Duration::from_secs(Self::DEFAULT_TTL_SECS))
}
pub(crate) fn get(
&self,
credential: &str,
detector_id: &str,
) -> Option<(VerificationResult, HashMap<String, String>)> {
self.get_with_companions(credential, detector_id, &HashMap::new())
}
pub(crate) fn get_with_companions(
&self,
credential: &str,
detector_id: &str,
companions: &HashMap<String, String>,
) -> Option<(VerificationResult, HashMap<String, String>)> {
let key = verification_identity(credential, detector_id, companions);
let now = Instant::now();
let entry = self.entries.get(&key)?;
if now < entry.expires_at {
return Some((entry.result.clone(), entry.metadata.clone()));
}
drop(entry);
if let dashmap::mapref::entry::Entry::Occupied(entry) = self.entries.entry(key) {
if now >= entry.get().expires_at {
entry.remove();
} else {
let entry = entry.get();
return Some((entry.result.clone(), entry.metadata.clone()));
}
}
None
}
pub(crate) fn put(
&self,
credential: &str,
detector_id: &str,
result: VerificationResult,
metadata: HashMap<String, String>,
) {
self.put_with_companions(credential, detector_id, &HashMap::new(), result, metadata);
}
pub(crate) fn put_with_companions(
&self,
credential: &str,
detector_id: &str,
companions: &HashMap<String, String>,
result: VerificationResult,
metadata: HashMap<String, String>,
) {
let key = verification_identity(credential, detector_id, companions);
let insert_count = self.inserts.fetch_add(1, Ordering::Relaxed) + 1;
if insert_count.is_multiple_of(Self::EVICTION_INTERVAL) {
self.evict_expired();
}
let generation = self.generation.fetch_add(1, Ordering::Relaxed);
self.entries.insert(
key.clone(),
CacheEntry {
result,
metadata: sanitize_metadata(metadata),
expires_at: Instant::now() + self.ttl,
generation,
},
);
let needs_stale_sweep = {
let mut queue = self.queue.lock();
queue.push_back((key, generation));
queue.len() > self.max_entries.saturating_mul(2)
};
if needs_stale_sweep {
self.reconcile_queue_with_entries();
}
self.enforce_max_entries_bound();
}
pub(crate) fn queue_len(&self) -> usize {
self.queue.lock().len()
}
pub(crate) fn len(&self) -> usize {
self.entries.len()
}
pub(crate) fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub(crate) fn evict_expired(&self) {
let now = Instant::now();
self.entries.retain(|_, entry| now < entry.expires_at);
self.reconcile_queue_with_entries();
}
pub(crate) fn enforce_max_entries_bound(&self) {
while self.entries.len() > self.max_entries {
if !self.evict_one_oldest() && !self.evict_any_entry() {
break;
}
}
}
fn reconcile_queue_with_entries(&self) {
let mut queue = self.queue.lock();
queue.retain(|(key, generation)| {
self.entries
.get(key)
.is_some_and(|entry| entry.generation == *generation)
});
while queue.len() > self.max_entries {
if let Some((key, _)) = queue.pop_front() {
self.entries.remove(&key);
} else {
break;
}
}
}
fn evict_one_oldest(&self) -> bool {
let mut queue = self.queue.lock();
while let Some((key, generation)) = queue.pop_front() {
if self
.entries
.remove_if(&key, |_, entry| entry.generation == generation)
.is_some()
{
return true;
}
}
false
}
fn evict_any_entry(&self) -> bool {
let key = self.entries.iter().next().map(|entry| entry.key().clone());
match key {
Some(key) => self.entries.remove(&key).is_some(),
None => false,
}
}
pub(crate) fn clear_eviction_queue_for_test(&self) {
self.queue.lock().clear();
}
pub(crate) fn insert_unqueued_for_test(
&self,
credential: &str,
detector_id: &str,
result: VerificationResult,
metadata: HashMap<String, String>,
) {
self.entries.insert(
verification_identity(credential, detector_id, &HashMap::new()),
CacheEntry {
result,
metadata: sanitize_metadata(metadata),
expires_at: Instant::now() + self.ttl,
generation: self.generation.fetch_add(1, Ordering::Relaxed),
},
);
}
}
pub fn oldest_eviction_batch(max_entries: usize) -> usize {
(max_entries / 8).max(1)
}
pub(crate) fn evict_oldest_dashmap_entries<K, V>(
cache: &DashMap<K, V>,
count: usize,
age_of: impl Fn(&V) -> Instant,
) where
K: Eq + std::hash::Hash + Clone,
{
if count == 0 {
return;
}
let mut by_age: Vec<(K, Instant)> = cache
.iter()
.map(|entry| (entry.key().clone(), age_of(entry.value())))
.collect();
if count < by_age.len() {
by_age.select_nth_unstable_by_key(count, |(_, inserted_at)| *inserted_at);
by_age.truncate(count);
}
for (key, _) in by_age {
cache.remove(&key);
}
}
pub(crate) fn verification_identity(
credential: &str,
detector_id: &str,
companions: &HashMap<String, String>,
) -> VerificationIdentity {
let mut companion_rows: Vec<(&str, &str)> = companions
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
.collect();
companion_rows.sort_unstable();
let mut companions_hasher = Sha256::new();
companions_hasher.update(b"keyhog-verification-companions-v1\0");
companions_hasher.update((companion_rows.len() as u64).to_le_bytes());
for (name, value) in companion_rows {
companions_hasher.update((name.len() as u64).to_le_bytes());
companions_hasher.update(name.as_bytes());
companions_hasher.update((value.len() as u64).to_le_bytes());
companions_hasher.update(value.as_bytes());
}
VerificationIdentity {
credential_hash: sha256_hash(credential),
detector_id_hash: sha256_hash(detector_id),
companions_hash: CredentialHash::from_bytes(companions_hasher.finalize().into()),
}
}
const PRIORITY_METADATA_KEYS: &[&str] = &[
"arn",
"account_id",
"user_id",
"oob_observed",
"oob_unique_id",
"oob_protocol",
"oob_remote_address",
];
fn metadata_priority_rank(key: &str) -> usize {
PRIORITY_METADATA_KEYS
.iter()
.position(|k| *k == key)
.map_or(PRIORITY_METADATA_KEYS.len(), |rank| rank)
}
fn sanitize_metadata(metadata: HashMap<String, String>) -> HashMap<String, String> {
let mut entries: Vec<(String, String)> = metadata.into_iter().collect();
entries.sort_unstable_by(|(a, _), (b, _)| {
metadata_priority_rank(a)
.cmp(&metadata_priority_rank(b))
.then_with(|| a.cmp(b))
});
entries
.into_iter()
.take(VerificationCache::MAX_METADATA_ENTRIES)
.map(|(key, value)| {
(
truncate_to_char_boundary(&key, VerificationCache::MAX_METADATA_KEY_BYTES),
truncate_to_char_boundary(&value, VerificationCache::MAX_METADATA_VALUE_BYTES),
)
})
.collect()
}
fn truncate_to_char_boundary(value: &str, max_bytes: usize) -> String {
if value.len() <= max_bytes {
return value.to_string();
}
let mut end = max_bytes;
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_string()
}