use alloc::borrow::Cow;
use alloc::collections::VecDeque;
use alloc::string::{String, ToString};
use alloc::sync::Arc;
use alloc::vec::Vec;
use core::sync::atomic::{AtomicBool, Ordering};
use core::time::Duration;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Instant;
use crate::Result;
use crate::codex::Codex;
use crate::decoy::DecoyStrategy;
use crate::error::Error;
use crate::fetcher::RawKey;
use crate::fragment::{FragmentStrategy, Fragments, StandardFragmenter};
use crate::monitor::{AccessContext, FailureContext, SecurityMonitor, ThresholdContext};
use crate::normalize::blake3_normalize;
const DEFAULT_MAX_FAILURES: u32 = 0;
const DEFAULT_FAILURE_WINDOW: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct VaultConfig {
pub key_normalization: bool,
pub max_failures_before_lockout: u32,
pub failure_window: Duration,
}
impl Default for VaultConfig {
fn default() -> Self {
Self::new()
}
}
impl VaultConfig {
#[must_use]
pub fn new() -> Self {
Self {
key_normalization: true,
max_failures_before_lockout: DEFAULT_MAX_FAILURES,
failure_window: DEFAULT_FAILURE_WINDOW,
}
}
}
#[derive(Clone)]
pub struct KeyVault {
inner: Arc<VaultInner>,
}
struct VaultInner {
config: VaultConfig,
fragmenter: StandardFragmenter,
codex: Option<Arc<dyn Codex>>,
monitor: Arc<dyn SecurityMonitor>,
failure_tracker: Mutex<HashMap<String, VecDeque<Instant>>>,
locked_out: AtomicBool,
}
impl KeyVault {
#[must_use]
pub fn is_locked_out(&self) -> bool {
self.inner.locked_out.load(Ordering::Acquire)
}
pub fn clear_lockout(&self) {
self.inner.locked_out.store(false, Ordering::Release);
if let Ok(mut tracker) = self.inner.failure_tracker.lock() {
tracker.clear();
}
}
pub fn report_failure(&self, key_name: &str, note: Option<&'static str>) {
let note = note.map_or(Cow::Borrowed(""), Cow::Borrowed);
let (count, oldest_in_window) = self.record_failure(key_name);
let window_elapsed = oldest_in_window.map(|t| t.elapsed()).unwrap_or_default();
let ctx = FailureContext {
key_name: key_name.to_string(),
consecutive_failures: count,
window_elapsed,
note: note.clone(),
};
self.inner.monitor.on_decryption_failure(&ctx);
let threshold = self.inner.config.max_failures_before_lockout;
if threshold > 0 && count >= threshold {
let was_locked = self.inner.locked_out.swap(true, Ordering::AcqRel);
let breach = ThresholdContext {
key_name: key_name.to_string(),
failures_in_window: count,
window: self.inner.config.failure_window,
lockout_triggered: !was_locked,
};
self.inner.monitor.on_threshold_breach(&breach);
}
}
pub fn report_anomalous_access(&self, key_name: &str, note: Option<&'static str>) {
let note = note.map_or(Cow::Borrowed(""), Cow::Borrowed);
let ctx = AccessContext {
key_name: key_name.to_string(),
note,
};
self.inner.monitor.on_anomalous_access(&ctx);
}
fn record_failure(&self, key_name: &str) -> (u32, Option<Instant>) {
let now = Instant::now();
let window = self.inner.config.failure_window;
let Ok(mut tracker) = self.inner.failure_tracker.lock() else {
return (1, Some(now));
};
let entries = tracker.entry(key_name.to_string()).or_default();
while let Some(front) = entries.front() {
if now.saturating_duration_since(*front) > window {
let _ = entries.pop_front();
} else {
break;
}
}
entries.push_back(now);
let count = u32::try_from(entries.len()).unwrap_or(u32::MAX);
let oldest = entries.front().copied();
(count, oldest)
}
#[must_use]
pub fn config(&self) -> &VaultConfig {
&self.inner.config
}
pub fn fragment(&self, key: &RawKey) -> Result<Fragments> {
if self.is_locked_out() {
return Err(Error::LockedOut);
}
let working = if self.inner.config.key_normalization {
blake3_normalize(key)
} else {
RawKey::new(key.as_bytes().to_vec())
};
let encoded = if let Some(codex) = &self.inner.codex {
codex_apply(codex.as_ref(), &working)
} else {
working
};
self.inner.fragmenter.fragment(&encoded)
}
pub fn defragment(&self, fragments: &Fragments) -> Result<RawKey> {
if self.is_locked_out() {
return Err(Error::LockedOut);
}
let encoded = self.inner.fragmenter.defragment(fragments)?;
if let Some(codex) = &self.inner.codex {
Ok(codex_apply(codex.as_ref(), &encoded))
} else {
Ok(encoded)
}
}
}
fn codex_apply(codex: &dyn Codex, key: &RawKey) -> RawKey {
let bytes: Vec<u8> = key.as_bytes().iter().map(|&b| codex.encode(b)).collect();
RawKey::new(bytes)
}
impl core::fmt::Debug for KeyVault {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("KeyVault")
.field("locked_out", &self.is_locked_out())
.field("config", &self.inner.config)
.finish()
}
}
#[derive(Clone)]
pub struct KeyVaultBuilder {
config: VaultConfig,
fragmenter: StandardFragmenter,
codex: Option<Arc<dyn Codex>>,
monitor: Option<Arc<dyn SecurityMonitor>>,
}
impl Default for KeyVaultBuilder {
fn default() -> Self {
Self::new()
}
}
impl core::fmt::Debug for KeyVaultBuilder {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("KeyVaultBuilder")
.field("config", &self.config)
.field("fragmenter", &self.fragmenter)
.field("codex", &self.codex.as_ref().map(|_| "<set>"))
.field("monitor", &self.monitor.as_ref().map(|_| "<set>"))
.finish()
}
}
impl KeyVaultBuilder {
#[must_use]
pub fn new() -> Self {
Self {
config: VaultConfig::new(),
fragmenter: StandardFragmenter::new(),
codex: None,
monitor: None,
}
}
#[must_use]
pub fn normalize_with_blake3(mut self, enabled: bool) -> Self {
self.config.key_normalization = enabled;
self
}
#[must_use]
pub fn with_chunk_range(mut self, min: usize, max: usize) -> Self {
self.fragmenter = StandardFragmenter::with_chunk_range(min, max);
self
}
#[must_use]
pub fn with_codex<C>(mut self, codex: C) -> Self
where
C: Codex + 'static,
{
self.codex = Some(Arc::new(codex));
self
}
#[must_use]
pub fn with_decoy<D>(mut self, decoy: D) -> Self
where
D: DecoyStrategy + 'static,
{
self.fragmenter = self.fragmenter.with_decoy(decoy);
self
}
#[must_use]
pub fn with_monitor<M>(mut self, monitor: M) -> Self
where
M: SecurityMonitor + 'static,
{
self.monitor = Some(Arc::new(monitor));
self
}
#[must_use]
pub fn with_failure_threshold(mut self, max: u32, window: Duration) -> Self {
self.config.max_failures_before_lockout = max;
self.config.failure_window = window;
self
}
#[must_use]
pub fn build(self) -> KeyVault {
let monitor: Arc<dyn SecurityMonitor> = self
.monitor
.unwrap_or_else(|| Arc::new(crate::monitor::NoMonitor));
KeyVault {
inner: Arc::new(VaultInner {
config: self.config,
fragmenter: self.fragmenter,
codex: self.codex,
monitor,
failure_tracker: Mutex::new(HashMap::new()),
locked_out: AtomicBool::new(false),
}),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use alloc::format;
#[test]
fn builder_defaults_to_normalization_on() {
let v = KeyVaultBuilder::new().build();
assert!(v.config().key_normalization);
}
#[test]
fn builder_can_disable_normalization() {
let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
assert!(!v.config().key_normalization);
}
#[test]
fn fresh_vault_is_not_locked_out() {
let v = KeyVaultBuilder::new().build();
assert!(!v.is_locked_out());
}
#[test]
fn debug_does_not_panic() {
let v = KeyVaultBuilder::new().build();
let _ = format!("{v:?}");
}
#[test]
fn fragment_defragment_roundtrip_with_normalization() {
let v = KeyVaultBuilder::new().build(); let raw = RawKey::new(b"hello world".to_vec());
let frags = v.fragment(&raw).unwrap();
let recovered = v.defragment(&frags).unwrap();
assert_eq!(recovered.len(), 32);
let frags2 = v.fragment(&raw).unwrap();
let recovered2 = v.defragment(&frags2).unwrap();
assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
}
#[test]
fn fragment_defragment_roundtrip_without_normalization() {
let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
let raw = RawKey::new((0u8..40).collect());
let frags = v.fragment(&raw).unwrap();
let recovered = v.defragment(&frags).unwrap();
assert_eq!(recovered.as_bytes(), raw.as_bytes());
}
#[test]
fn fragment_rejects_empty_key() {
let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
let err = v
.fragment(&RawKey::new(alloc::vec::Vec::new()))
.unwrap_err();
assert!(matches!(err, crate::Error::Fragment(_)));
}
#[test]
fn chunk_range_propagates_through_builder() {
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_chunk_range(4, 6)
.build();
let raw = RawKey::new((0u8..30).collect());
let frags = v.fragment(&raw).unwrap();
let chunks = frags.chunks();
let mut below_min = 0;
let mut total = 0usize;
for c in chunks {
assert!(
c.len() >= 1 && c.len() <= 6,
"chunk size {} not in [1,6]",
c.len()
);
if c.len() < 4 {
below_min += 1;
}
total += c.len();
}
assert!(
below_min <= 1,
"more than one chunk below min size: {below_min}"
);
assert_eq!(total, 30);
}
#[test]
fn fragment_with_random_decoy_roundtrips() {
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_decoy(crate::RandomDecoy)
.build();
let raw = RawKey::new((0u8..32).collect());
let frags = v.fragment(&raw).unwrap();
let recovered = v.defragment(&frags).unwrap();
assert_eq!(recovered.as_bytes(), raw.as_bytes());
}
#[test]
fn fragment_with_self_reference_decoy_roundtrips() {
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_decoy(crate::SelfReferenceDecoy)
.build();
let raw = RawKey::new(b"some user-supplied key material".to_vec());
let frags = v.fragment(&raw).unwrap();
let recovered = v.defragment(&frags).unwrap();
assert_eq!(recovered.as_bytes(), raw.as_bytes());
}
#[test]
fn fragment_with_key_derived_decoy_roundtrips() {
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_decoy(crate::KeyDerivedDecoy)
.build();
let raw = RawKey::new((0u8..64).collect());
let frags = v.fragment(&raw).unwrap();
let recovered = v.defragment(&frags).unwrap();
assert_eq!(recovered.as_bytes(), raw.as_bytes());
}
#[test]
fn decoy_increases_chunk_count_relative_to_no_decoy() {
let no_decoy = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_chunk_range(2, 4)
.build();
let with_decoy = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_chunk_range(2, 4)
.with_decoy(crate::SelfReferenceDecoy)
.build();
let raw = RawKey::new((0u8..32).collect());
let mut no_decoy_total = 0usize;
let mut decoy_total = 0usize;
for _ in 0..8 {
no_decoy_total += no_decoy.fragment(&raw).unwrap().chunk_count();
decoy_total += with_decoy.fragment(&raw).unwrap().chunk_count();
}
assert!(
decoy_total > no_decoy_total,
"decoy vault produced {decoy_total} chunks vs no-decoy {no_decoy_total}"
);
}
#[test]
fn fragment_with_static_codex_roundtrips() {
use crate::StaticCodex;
let codex = StaticCodex::from_swaps(&[(b'A', b'#'), (b'0', b'%')]).unwrap();
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_codex(codex)
.build();
let raw = RawKey::new(b"A0A0A0A0".to_vec());
let frags = v.fragment(&raw).unwrap();
let recovered = v.defragment(&frags).unwrap();
assert_eq!(recovered.as_bytes(), raw.as_bytes());
}
#[test]
fn fragment_with_dynamic_codex_roundtrips() {
use crate::DynamicCodex;
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_codex(DynamicCodex::new().unwrap())
.build();
let raw = RawKey::new((0u8..=255).collect());
let frags = v.fragment(&raw).unwrap();
let recovered = v.defragment(&frags).unwrap();
assert_eq!(recovered.as_bytes(), raw.as_bytes());
}
#[test]
fn fragment_with_codex_and_decoy_and_normalization_roundtrips() {
use crate::{DynamicCodex, SelfReferenceDecoy};
let v = KeyVaultBuilder::new()
.normalize_with_blake3(true)
.with_codex(DynamicCodex::new().unwrap())
.with_decoy(SelfReferenceDecoy)
.build();
let raw = RawKey::new(b"my application key".to_vec());
let frags = v.fragment(&raw).unwrap();
let recovered = v.defragment(&frags).unwrap();
assert_eq!(recovered.len(), 32);
let recovered2 = v.defragment(&v.fragment(&raw).unwrap()).unwrap();
assert_eq!(recovered.as_bytes(), recovered2.as_bytes());
}
#[test]
fn codex_visibly_transforms_stored_bytes() {
use crate::StaticCodex;
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_codex(crate::DynamicCodex::new().unwrap())
.build();
let raw = RawKey::new(alloc::vec![0xaa; 8]);
let frags = v.fragment(&raw).unwrap();
let mut saw_non_aa = false;
for chunk in frags.chunks() {
for &b in chunk.as_bytes() {
if b != 0xaa {
saw_non_aa = true;
break;
}
}
if saw_non_aa {
break;
}
}
assert!(
saw_non_aa,
"codex did not transform 0xaa — stored bytes still all 0xaa",
);
let recovered = v.defragment(&frags).unwrap();
assert_eq!(recovered.as_bytes(), raw.as_bytes());
let _ = StaticCodex::from_swaps(&[]).unwrap();
}
use core::sync::atomic::AtomicU32;
struct CountingMonitor {
failures: AtomicU32,
anomalies: AtomicU32,
breaches: AtomicU32,
}
impl CountingMonitor {
fn new() -> Self {
Self {
failures: AtomicU32::new(0),
anomalies: AtomicU32::new(0),
breaches: AtomicU32::new(0),
}
}
}
impl SecurityMonitor for CountingMonitor {
fn on_decryption_failure(&self, _ctx: &FailureContext) {
let _ = self.failures.fetch_add(1, Ordering::SeqCst);
}
fn on_anomalous_access(&self, _ctx: &AccessContext) {
let _ = self.anomalies.fetch_add(1, Ordering::SeqCst);
}
fn on_threshold_breach(&self, _ctx: &ThresholdContext) {
let _ = self.breaches.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn report_failure_fires_monitor() {
let monitor = Arc::new(CountingMonitor::new());
let v = KeyVaultBuilder::new()
.with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
.build();
v.report_failure("k", None);
v.report_failure("k", Some("test note"));
assert_eq!(monitor.failures.load(Ordering::SeqCst), 2);
assert_eq!(monitor.breaches.load(Ordering::SeqCst), 0);
assert!(!v.is_locked_out());
}
#[test]
fn report_anomalous_access_fires_monitor() {
let monitor = Arc::new(CountingMonitor::new());
let v = KeyVaultBuilder::new()
.with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
.build();
v.report_anomalous_access("k", None);
assert_eq!(monitor.anomalies.load(Ordering::SeqCst), 1);
assert!(!v.is_locked_out());
}
#[test]
fn threshold_lockout_fires_after_max_failures() {
let monitor = Arc::new(CountingMonitor::new());
let v = KeyVaultBuilder::new()
.with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
.with_failure_threshold(3, Duration::from_secs(30))
.build();
v.report_failure("k", None);
assert!(!v.is_locked_out());
v.report_failure("k", None);
assert!(!v.is_locked_out());
v.report_failure("k", None);
assert!(v.is_locked_out());
assert_eq!(monitor.failures.load(Ordering::SeqCst), 3);
assert_eq!(monitor.breaches.load(Ordering::SeqCst), 1);
v.report_failure("k", None);
assert!(v.is_locked_out());
assert_eq!(monitor.failures.load(Ordering::SeqCst), 4);
assert_eq!(monitor.breaches.load(Ordering::SeqCst), 2);
}
#[test]
fn fragment_refuses_when_locked_out() {
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_failure_threshold(1, Duration::from_secs(30))
.build();
v.report_failure("k", None);
assert!(v.is_locked_out());
let err = v
.fragment(&RawKey::new(alloc::vec![1u8, 2, 3, 4]))
.unwrap_err();
assert!(matches!(err, Error::LockedOut));
}
#[test]
fn defragment_refuses_when_locked_out() {
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_failure_threshold(2, Duration::from_secs(30))
.build();
let raw = RawKey::new(alloc::vec![1u8; 16]);
let frags = v.fragment(&raw).unwrap();
v.report_failure("k", None);
v.report_failure("k", None);
assert!(v.is_locked_out());
let err = v.defragment(&frags).unwrap_err();
assert!(matches!(err, Error::LockedOut));
}
#[test]
fn clear_lockout_resets_state() {
let v = KeyVaultBuilder::new()
.with_failure_threshold(1, Duration::from_secs(30))
.build();
v.report_failure("k", None);
assert!(v.is_locked_out());
v.clear_lockout();
assert!(!v.is_locked_out());
v.clear_lockout();
assert!(!v.is_locked_out());
}
#[test]
fn per_key_failure_counts_are_independent() {
let monitor = Arc::new(CountingMonitor::new());
let v = KeyVaultBuilder::new()
.with_monitor(Arc::clone(&monitor) as Arc<dyn SecurityMonitor>)
.with_failure_threshold(2, Duration::from_secs(30))
.build();
v.report_failure("alpha", None);
v.report_failure("beta", None);
assert!(!v.is_locked_out());
assert_eq!(monitor.failures.load(Ordering::SeqCst), 2);
v.report_failure("alpha", None);
assert!(v.is_locked_out());
}
#[test]
fn composite_monitor_chains_to_all_inner() {
use crate::CompositeMonitor;
let a = Arc::new(CountingMonitor::new());
let b = Arc::new(CountingMonitor::new());
let composite = CompositeMonitor::new(alloc::vec![
Arc::clone(&a) as Arc<dyn SecurityMonitor>,
Arc::clone(&b) as Arc<dyn SecurityMonitor>,
]);
let v = KeyVaultBuilder::new()
.with_monitor(composite)
.with_failure_threshold(1, Duration::from_secs(30))
.build();
v.report_failure("k", None);
assert_eq!(a.failures.load(Ordering::SeqCst), 1);
assert_eq!(b.failures.load(Ordering::SeqCst), 1);
assert_eq!(a.breaches.load(Ordering::SeqCst), 1);
assert_eq!(b.breaches.load(Ordering::SeqCst), 1);
}
}