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, SystemTime, UNIX_EPOCH};
use arc_swap::ArcSwap;
use subtle::ConstantTimeEq;
use crate::Result;
use crate::audit::{AccessKind, AuditEvent, AuditSink};
use crate::codex::Codex;
use crate::decoy::DecoyStrategy;
use crate::error::Error;
use crate::fetcher::RawKey;
use crate::fragment::{FragmentStrategy, Fragments, StandardFragmenter};
use crate::handle::{KeyHandle, KeyId};
use crate::metadata::KeyMetadata;
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>,
}
#[derive(Clone)]
struct KeyEntry {
name: String,
fragments: Arc<Fragments>,
metadata: KeyMetadata,
}
struct VaultInner {
config: VaultConfig,
fragmenter: StandardFragmenter,
codex: Option<Arc<dyn Codex>>,
monitor: Arc<dyn SecurityMonitor>,
keys: ArcSwap<HashMap<KeyId, KeyEntry>>,
failure_tracker: Mutex<HashMap<String, VecDeque<Instant>>>,
locked_out: AtomicBool,
master_hash: Option<[u8; 32]>,
audit: Arc<dyn AuditSink>,
}
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 emit_audit(&self, key_name: &str, kind: AccessKind, note: Cow<'static, str>) {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let event = AuditEvent {
timestamp,
key_name: key_name.to_string(),
kind,
thread_id: std::thread::current().id(),
note,
};
self.inner.audit.on_event(&event);
}
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
};
let result = self.inner.fragmenter.fragment(&encoded);
if result.is_ok() {
self.emit_audit("", AccessKind::OneShotFragment, Cow::Borrowed(""));
}
result
}
pub fn defragment(&self, fragments: &Fragments) -> Result<RawKey> {
if self.is_locked_out() {
return Err(Error::LockedOut);
}
let encoded = self.inner.fragmenter.defragment(fragments)?;
let decoded = if let Some(codex) = &self.inner.codex {
codex_apply(codex.as_ref(), &encoded)
} else {
encoded
};
self.emit_audit("", AccessKind::OneShotDefragment, Cow::Borrowed(""));
Ok(decoded)
}
#[allow(clippy::needless_pass_by_value)]
pub fn register(&self, name: impl Into<String>, key: RawKey) -> Result<KeyHandle> {
if self.is_locked_out() {
return Err(Error::LockedOut);
}
let name: String = name.into();
let snapshot = self.inner.keys.load();
if snapshot.values().any(|e| e.name == name) {
return Err(Error::InvalidConfig(format!(
"key name {name:?} is already registered"
)));
}
drop(snapshot);
let key_len = key.len();
let fragments = self.fragment(&key)?;
let handle = KeyHandle::allocate();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let metadata = KeyMetadata::new(now, key_len, None);
let entry = KeyEntry {
name,
fragments: Arc::new(fragments),
metadata,
};
let _previous = self.inner.keys.rcu(|current| {
let mut new_map = (**current).clone();
let _ = new_map.insert(
handle.id(),
KeyEntry {
name: entry.name.clone(),
fragments: Arc::clone(&entry.fragments),
metadata: entry.metadata.clone(),
},
);
new_map
});
self.emit_audit(&entry.name, AccessKind::Register, Cow::Borrowed(""));
Ok(handle)
}
pub fn unregister(&self, handle: KeyHandle) -> Result<()> {
let name = self
.inner
.keys
.load()
.get(&handle.id())
.map(|e| e.name.clone());
let mut removed = false;
let _previous = self.inner.keys.rcu(|current| {
let mut new_map = (**current).clone();
removed = new_map.remove(&handle.id()).is_some();
new_map
});
if removed {
if let Some(name) = name {
self.emit_audit(&name, AccessKind::Unregister, Cow::Borrowed(""));
}
Ok(())
} else {
Err(Error::KeyNotFound)
}
}
pub fn with_key<F, T>(&self, handle: KeyHandle, f: F) -> Result<T>
where
F: FnOnce(&[u8]) -> T,
{
if self.is_locked_out() {
return Err(Error::LockedOut);
}
let snapshot = self.inner.keys.load();
let entry = snapshot.get(&handle.id()).ok_or(Error::KeyNotFound)?;
let fragments = Arc::clone(&entry.fragments);
let name = entry.name.clone();
drop(snapshot);
let encoded = self.inner.fragmenter.defragment(&fragments)?;
let raw = if let Some(codex) = &self.inner.codex {
codex_apply(codex.as_ref(), &encoded)
} else {
encoded
};
let result = f(raw.as_bytes());
self.emit_audit(&name, AccessKind::Read, Cow::Borrowed(""));
Ok(result)
}
#[allow(clippy::needless_pass_by_value)]
pub fn rotate(&self, handle: KeyHandle, new_key: RawKey) -> Result<()> {
if self.is_locked_out() {
return Err(Error::LockedOut);
}
let name = {
let snapshot = self.inner.keys.load();
snapshot
.get(&handle.id())
.map(|e| e.name.clone())
.ok_or(Error::KeyNotFound)?
};
let new_len = new_key.len();
let new_fragments = Arc::new(self.fragment(&new_key)?);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let new_metadata = KeyMetadata::new(now, new_len, None);
let mut found = false;
let _previous = self.inner.keys.rcu(|current| {
let mut new_map = (**current).clone();
if let Some(entry) = new_map.get_mut(&handle.id()) {
entry.fragments = Arc::clone(&new_fragments);
entry.metadata = new_metadata.clone();
found = true;
}
new_map
});
if found {
self.emit_audit(&name, AccessKind::Rotate, Cow::Borrowed(""));
Ok(())
} else {
Err(Error::KeyNotFound)
}
}
#[must_use]
pub fn contains(&self, handle: KeyHandle) -> bool {
self.inner.keys.load().contains_key(&handle.id())
}
#[must_use]
pub fn metadata(&self, handle: KeyHandle) -> Option<KeyMetadata> {
self.inner
.keys
.load()
.get(&handle.id())
.map(|e| e.metadata.clone())
}
#[must_use]
pub fn handle_for_name(&self, name: &str) -> Option<KeyHandle> {
self.inner
.keys
.load()
.iter()
.find_map(|(id, entry)| (entry.name == name).then(|| KeyHandle::from_id(*id)))
}
#[must_use]
pub fn key_count(&self) -> usize {
self.inner.keys.load().len()
}
pub fn unlock_with_master(&self, attempt: &[u8]) -> Result<()> {
let stored = self.inner.master_hash.ok_or_else(|| {
Error::InvalidConfig(
"vault has no master key registered; pass with_master_key at build time"
.to_string(),
)
})?;
let attempt_hash = blake3::hash(attempt);
let matched = bool::from(stored.as_slice().ct_eq(attempt_hash.as_bytes()));
self.emit_audit(
"<master>",
AccessKind::MasterUnlockAttempt { matched },
Cow::Borrowed(""),
);
if matched {
self.clear_lockout();
Ok(())
} else {
self.report_failure("<master>", Some("invalid master credential"));
Err(Error::Acquisition {
source: Cow::Borrowed("master"),
reason: "master credential did not match".to_string(),
})
}
}
#[must_use]
pub fn has_master_key(&self) -> bool {
self.inner.master_hash.is_some()
}
}
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>>,
audit: Option<Arc<dyn AuditSink>>,
master_hash: Option<[u8; 32]>,
}
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>"))
.field("audit", &self.audit.as_ref().map(|_| "<set>"))
.field("master_key", &self.master_hash.as_ref().map(|_| "<set>"))
.finish()
}
}
impl KeyVaultBuilder {
#[must_use]
pub fn new() -> Self {
Self {
config: VaultConfig::new(),
fragmenter: StandardFragmenter::new(),
codex: None,
monitor: None,
audit: None,
master_hash: 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 with_audit_sink<A>(mut self, sink: A) -> Self
where
A: AuditSink + 'static,
{
self.audit = Some(Arc::new(sink));
self
}
#[must_use]
pub fn with_master_key(mut self, master: RawKey) -> Self {
let hash = blake3::hash(master.as_bytes());
let mut bytes = [0u8; 32];
bytes.copy_from_slice(hash.as_bytes());
self.master_hash = Some(bytes);
drop(master);
self
}
#[must_use]
pub fn build(self) -> KeyVault {
let monitor: Arc<dyn SecurityMonitor> = self
.monitor
.unwrap_or_else(|| Arc::new(crate::monitor::NoMonitor));
let audit: Arc<dyn AuditSink> = self
.audit
.unwrap_or_else(|| Arc::new(crate::audit::NoAudit));
KeyVault {
inner: Arc::new(VaultInner {
config: self.config,
fragmenter: self.fragmenter,
codex: self.codex,
monitor,
keys: ArcSwap::from_pointee(HashMap::new()),
failure_tracker: Mutex::new(HashMap::new()),
locked_out: AtomicBool::new(false),
master_hash: self.master_hash,
audit,
}),
}
}
}
#[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 register_returns_handle_and_increments_count() {
let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
assert_eq!(v.key_count(), 0);
let h = v
.register("primary", RawKey::new(alloc::vec![1u8; 32]))
.unwrap();
assert_eq!(v.key_count(), 1);
assert!(v.contains(h));
}
#[test]
fn register_rejects_duplicate_name() {
let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
let _ = v
.register("primary", RawKey::new(alloc::vec![1u8; 16]))
.unwrap();
let err = v
.register("primary", RawKey::new(alloc::vec![2u8; 16]))
.unwrap_err();
assert!(matches!(err, Error::InvalidConfig(_)));
}
#[test]
fn unregister_removes_key() {
let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
let h = v
.register("primary", RawKey::new(alloc::vec![1u8; 16]))
.unwrap();
assert!(v.contains(h));
v.unregister(h).unwrap();
assert!(!v.contains(h));
assert_eq!(v.key_count(), 0);
}
#[test]
fn unregister_unknown_handle_errors() {
let v = KeyVaultBuilder::new().build();
let h = KeyHandle::__for_test();
let err = v.unregister(h).unwrap_err();
assert!(matches!(err, Error::KeyNotFound));
}
#[test]
fn with_key_round_trips_bytes() {
let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
let original = alloc::vec![0xa5u8; 32];
let h = v.register("data", RawKey::new(original.clone())).unwrap();
let observed = v.with_key(h, <[u8]>::to_vec).unwrap();
assert_eq!(observed, original);
}
#[test]
fn with_key_normalization_changes_output_length() {
let v = KeyVaultBuilder::new().build(); let h = v
.register("data", RawKey::new(alloc::vec![0xa5; 17]))
.unwrap();
let observed_len = v.with_key(h, <[u8]>::len).unwrap();
assert_eq!(observed_len, 32);
}
#[test]
fn with_key_unknown_handle_errors() {
let v = KeyVaultBuilder::new().build();
let h = KeyHandle::__for_test();
let err = v.with_key(h, |_| ()).unwrap_err();
assert!(matches!(err, Error::KeyNotFound));
}
#[test]
fn rotate_swaps_key_bytes() {
let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
let h = v
.register("data", RawKey::new(alloc::vec![1u8; 16]))
.unwrap();
v.rotate(h, RawKey::new(alloc::vec![2u8; 16])).unwrap();
let observed = v.with_key(h, <[u8]>::to_vec).unwrap();
assert_eq!(observed, alloc::vec![2u8; 16]);
}
#[test]
fn rotate_unknown_handle_errors() {
let v = KeyVaultBuilder::new().build();
let h = KeyHandle::__for_test();
let err = v.rotate(h, RawKey::new(alloc::vec![0u8; 16])).unwrap_err();
assert!(matches!(err, Error::KeyNotFound));
}
#[test]
fn handle_for_name_finds_registered_key() {
let v = KeyVaultBuilder::new().build();
let h = v
.register("primary", RawKey::new(alloc::vec![0u8; 16]))
.unwrap();
assert_eq!(v.handle_for_name("primary"), Some(h));
assert_eq!(v.handle_for_name("missing"), None);
}
#[test]
fn metadata_records_registration_length() {
let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
let h = v
.register("data", RawKey::new(alloc::vec![0u8; 42]))
.unwrap();
let meta = v.metadata(h).expect("metadata");
assert_eq!(meta.length(), 42);
}
#[test]
fn registered_key_refuses_access_when_locked_out() {
let v = KeyVaultBuilder::new()
.with_failure_threshold(1, Duration::from_secs(30))
.build();
let h = v
.register("data", RawKey::new(alloc::vec![0xa5; 16]))
.unwrap();
v.report_failure("data", None);
assert!(v.is_locked_out());
let err = v.with_key(h, |_| ()).unwrap_err();
assert!(matches!(err, Error::LockedOut));
let err = v.rotate(h, RawKey::new(alloc::vec![0u8; 16])).unwrap_err();
assert!(matches!(err, Error::LockedOut));
}
#[test]
fn master_key_unlock_clears_lockout_on_match() {
let master_bytes = b"correct horse battery staple".to_vec();
let v = KeyVaultBuilder::new()
.with_master_key(RawKey::new(master_bytes.clone()))
.with_failure_threshold(1, Duration::from_secs(30))
.build();
assert!(v.has_master_key());
v.report_failure("k", None);
assert!(v.is_locked_out());
let err = v.unlock_with_master(b"wrong").unwrap_err();
assert!(matches!(err, Error::Acquisition { .. }));
assert!(v.is_locked_out());
v.unlock_with_master(&master_bytes).unwrap();
assert!(!v.is_locked_out());
}
struct CapturingAudit {
events: Mutex<Vec<(crate::audit::AccessKind, String)>>,
}
impl CapturingAudit {
fn new() -> Self {
Self {
events: Mutex::new(Vec::new()),
}
}
fn count_of(&self, kind: crate::audit::AccessKind) -> usize {
self.events
.lock()
.unwrap()
.iter()
.filter(|(k, _)| *k == kind)
.count()
}
fn last_for(&self, kind: crate::audit::AccessKind) -> Option<String> {
self.events
.lock()
.unwrap()
.iter()
.rev()
.find_map(|(k, name)| (*k == kind).then(|| name.clone()))
}
}
impl crate::audit::AuditSink for CapturingAudit {
fn on_event(&self, event: &crate::audit::AuditEvent) {
self.events
.lock()
.unwrap()
.push((event.kind, event.key_name.clone()));
}
}
#[test]
fn register_emits_register_event() {
let audit = Arc::new(CapturingAudit::new());
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
.build();
let _ = v
.register("primary", RawKey::new(alloc::vec![1u8; 16]))
.unwrap();
assert_eq!(audit.count_of(crate::audit::AccessKind::Register), 1);
assert_eq!(
audit.last_for(crate::audit::AccessKind::Register),
Some("primary".to_string())
);
}
#[test]
fn unregister_emits_unregister_event() {
let audit = Arc::new(CapturingAudit::new());
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
.build();
let h = v
.register("primary", RawKey::new(alloc::vec![1u8; 16]))
.unwrap();
v.unregister(h).unwrap();
assert_eq!(audit.count_of(crate::audit::AccessKind::Unregister), 1);
assert_eq!(
audit.last_for(crate::audit::AccessKind::Unregister),
Some("primary".to_string())
);
}
#[test]
fn with_key_emits_read_event() {
let audit = Arc::new(CapturingAudit::new());
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
.build();
let h = v
.register("data", RawKey::new(alloc::vec![0xa5u8; 16]))
.unwrap();
let _ = v.with_key(h, <[u8]>::to_vec).unwrap();
assert_eq!(audit.count_of(crate::audit::AccessKind::Read), 1);
assert_eq!(
audit.last_for(crate::audit::AccessKind::Read),
Some("data".to_string())
);
}
#[test]
fn rotate_emits_rotate_event() {
let audit = Arc::new(CapturingAudit::new());
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
.build();
let h = v
.register("data", RawKey::new(alloc::vec![1u8; 16]))
.unwrap();
v.rotate(h, RawKey::new(alloc::vec![2u8; 16])).unwrap();
assert_eq!(audit.count_of(crate::audit::AccessKind::Rotate), 1);
assert_eq!(
audit.last_for(crate::audit::AccessKind::Rotate),
Some("data".to_string())
);
}
#[test]
fn fragment_and_defragment_emit_oneshot_events() {
let audit = Arc::new(CapturingAudit::new());
let v = KeyVaultBuilder::new()
.normalize_with_blake3(false)
.with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
.build();
let raw = RawKey::new(alloc::vec![0u8; 16]);
let frags = v.fragment(&raw).unwrap();
let _ = v.defragment(&frags).unwrap();
assert_eq!(audit.count_of(crate::audit::AccessKind::OneShotFragment), 1);
assert_eq!(
audit.count_of(crate::audit::AccessKind::OneShotDefragment),
1
);
}
#[test]
fn master_unlock_emits_event_with_match_status() {
let audit = Arc::new(CapturingAudit::new());
let master = b"correct".to_vec();
let v = KeyVaultBuilder::new()
.with_master_key(RawKey::new(master.clone()))
.with_failure_threshold(1, Duration::from_secs(30))
.with_audit_sink(Arc::clone(&audit) as Arc<dyn crate::audit::AuditSink>)
.build();
v.report_failure("k", None);
assert!(v.is_locked_out());
let _ = v.unlock_with_master(b"wrong");
assert_eq!(
audit.count_of(crate::audit::AccessKind::MasterUnlockAttempt { matched: false }),
1
);
v.unlock_with_master(&master).unwrap();
assert_eq!(
audit.count_of(crate::audit::AccessKind::MasterUnlockAttempt { matched: true }),
1
);
}
#[test]
fn no_audit_default_does_not_panic() {
let v = KeyVaultBuilder::new().normalize_with_blake3(false).build();
let h = v.register("k", RawKey::new(alloc::vec![0u8; 16])).unwrap();
let _ = v.with_key(h, <[u8]>::to_vec).unwrap();
v.unregister(h).unwrap();
}
#[test]
fn master_key_unlock_without_registered_master_errors() {
let v = KeyVaultBuilder::new().build();
assert!(!v.has_master_key());
let err = v.unlock_with_master(b"anything").unwrap_err();
assert!(matches!(err, Error::InvalidConfig(_)));
}
#[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);
}
}