Skip to main content

confium_tc_keys/
production_hardening.rs

1//! Production hardening: tamper-evident audit, session GC, signer quarantine,
2//! time-locked/conditional signing, cross-quorum aggregation, config reload,
3//! wallet policy, replay protection, proactive security.
4
5use chrono::{DateTime, Duration, Utc};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::{HashMap, HashSet};
9use std::sync::Mutex;
10use std::sync::atomic::{AtomicU64, Ordering};
11
12// === Tamper-Evident Audit Log (Merkle-backed) ===
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct TamperProofEntry {
16    pub sequence: u64,
17    pub timestamp: DateTime<Utc>,
18    pub event_type: String,
19    pub payload_hex: String,
20    pub prev_hash_hex: String,
21    pub entry_hash_hex: String,
22}
23
24pub struct TamperProofLog {
25    entries: Mutex<Vec<TamperProofEntry>>,
26    root: Mutex<String>,
27}
28
29impl TamperProofLog {
30    pub fn new() -> Self {
31        Self {
32            entries: Mutex::new(Vec::new()),
33            root: Mutex::new("0".repeat(64)),
34        }
35    }
36
37    pub fn append(&self, event_type: &str, payload: &[u8]) -> u64 {
38        let mut entries = self.entries.lock().unwrap();
39        let seq = entries.len() as u64 + 1;
40        let prev_hash = entries
41            .last()
42            .map(|e| e.entry_hash_hex.clone())
43            .unwrap_or_else(|| "0".repeat(64));
44        let mut hasher = Sha256::new();
45        hasher.update(seq.to_be_bytes());
46        hasher.update(event_type.as_bytes());
47        hasher.update(payload);
48        hasher.update(prev_hash.as_bytes());
49        let entry_hash = hex::encode(hasher.finalize());
50        let entry = TamperProofEntry {
51            sequence: seq,
52            timestamp: Utc::now(),
53            event_type: event_type.into(),
54            payload_hex: hex::encode(payload),
55            prev_hash_hex: prev_hash.clone(),
56            entry_hash_hex: entry_hash.clone(),
57        };
58        entries.push(entry);
59        *self.root.lock().unwrap() = entry_hash;
60        seq
61    }
62
63    pub fn verify_integrity(&self) -> bool {
64        let entries = self.entries.lock().unwrap();
65        let mut prev_hash = "0".repeat(64);
66        for entry in entries.iter() {
67            if entry.prev_hash_hex != prev_hash {
68                return false;
69            }
70            let mut hasher = Sha256::new();
71            hasher.update(entry.sequence.to_be_bytes());
72            hasher.update(entry.event_type.as_bytes());
73            let payload = match hex::decode(&entry.payload_hex) {
74                Ok(p) => p,
75                Err(_) => return false,
76            };
77            hasher.update(&payload);
78            hasher.update(entry.prev_hash_hex.as_bytes());
79            let computed = hex::encode(hasher.finalize());
80            if computed != entry.entry_hash_hex {
81                return false;
82            }
83            prev_hash = entry.entry_hash_hex.clone();
84        }
85        true
86    }
87
88    pub fn root_hash(&self) -> String {
89        self.root.lock().unwrap().clone()
90    }
91    pub fn entry_count(&self) -> usize {
92        self.entries.lock().unwrap().len()
93    }
94    pub fn entries(&self) -> Vec<TamperProofEntry> {
95        self.entries.lock().unwrap().clone()
96    }
97}
98
99impl Default for TamperProofLog {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105// === Session Garbage Collector ===
106
107#[derive(Debug, Clone)]
108pub struct GcConfig {
109    pub max_completed_age: Duration,
110    pub max_expired_age: Duration,
111    pub max_retained: usize,
112    pub gc_interval_secs: u64,
113}
114
115impl Default for GcConfig {
116    fn default() -> Self {
117        Self {
118            max_completed_age: Duration::hours(24),
119            max_expired_age: Duration::hours(48),
120            max_retained: 10_000,
121            gc_interval_secs: 300,
122        }
123    }
124}
125
126#[derive(Debug, Clone)]
127pub struct GcSessionInfo {
128    pub session_id: String,
129    pub state: String,
130    pub last_updated: DateTime<Utc>,
131}
132
133pub struct SessionGarbageCollector {
134    config: GcConfig,
135    collected: AtomicU64,
136}
137
138impl SessionGarbageCollector {
139    pub fn new(config: GcConfig) -> Self {
140        Self {
141            config,
142            collected: AtomicU64::new(0),
143        }
144    }
145
146    pub fn collect(&self, sessions: &mut Vec<GcSessionInfo>) -> usize {
147        let now = Utc::now();
148        let before = sessions.len();
149        sessions.retain(|s| {
150            let age = now - s.last_updated;
151            match s.state.as_str() {
152                "completed" => age < self.config.max_completed_age,
153                "expired" | "aborted" => age < self.config.max_expired_age,
154                _ => true, // never GC pending/active sessions
155            }
156        });
157        // Also enforce max_retained
158        if sessions.len() > self.config.max_retained {
159            let excess = sessions.len() - self.config.max_retained;
160            sessions.drain(..excess);
161        }
162        let collected = before - sessions.len();
163        self.collected.fetch_add(collected as u64, Ordering::SeqCst);
164        collected
165    }
166
167    pub fn total_collected(&self) -> u64 {
168        self.collected.load(Ordering::SeqCst)
169    }
170}
171
172// === Suspicious Signer Quarantine ===
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct SignerReputation {
176    pub signer_id: String,
177    pub good_count: u32,
178    pub bad_count: u32,
179    pub quarantined: bool,
180    pub quarantine_until: Option<DateTime<Utc>>,
181}
182
183pub struct SignerQuarantine {
184    reputations: Mutex<HashMap<String, SignerReputation>>,
185    bad_threshold: u32,
186    quarantine_duration: Duration,
187}
188
189impl SignerQuarantine {
190    pub fn new(bad_threshold: u32, quarantine_duration: Duration) -> Self {
191        Self {
192            reputations: Mutex::new(HashMap::new()),
193            bad_threshold,
194            quarantine_duration,
195        }
196    }
197
198    pub fn record_good(&self, signer_id: &str) {
199        let mut reps = self.reputations.lock().unwrap();
200        let rep = reps
201            .entry(signer_id.into())
202            .or_insert_with(|| SignerReputation {
203                signer_id: signer_id.into(),
204                good_count: 0,
205                bad_count: 0,
206                quarantined: false,
207                quarantine_until: None,
208            });
209        rep.good_count += 1;
210        if rep.quarantined && rep.good_count >= 10 {
211            rep.quarantined = false;
212            rep.quarantine_until = None;
213        }
214    }
215
216    pub fn record_bad(&self, signer_id: &str) {
217        let mut reps = self.reputations.lock().unwrap();
218        let rep = reps
219            .entry(signer_id.into())
220            .or_insert_with(|| SignerReputation {
221                signer_id: signer_id.into(),
222                good_count: 0,
223                bad_count: 0,
224                quarantined: false,
225                quarantine_until: None,
226            });
227        rep.bad_count += 1;
228        if rep.bad_count >= self.bad_threshold {
229            rep.quarantined = true;
230            rep.quarantine_until = Some(Utc::now() + self.quarantine_duration);
231        }
232    }
233
234    pub fn is_quarantined(&self, signer_id: &str) -> bool {
235        let reps = self.reputations.lock().unwrap();
236        reps.get(signer_id)
237            .map(|r| {
238                if !r.quarantined {
239                    return false;
240                }
241                if let Some(until) = r.quarantine_until {
242                    return Utc::now() < until;
243                }
244                true
245            })
246            .unwrap_or(false)
247    }
248
249    pub fn reputation(&self, signer_id: &str) -> Option<SignerReputation> {
250        self.reputations.lock().unwrap().get(signer_id).cloned()
251    }
252
253    pub fn quarantined_count(&self) -> usize {
254        self.reputations
255            .lock()
256            .unwrap()
257            .values()
258            .filter(|r| r.quarantined)
259            .count()
260    }
261
262    pub fn release(&self, signer_id: &str) {
263        if let Some(rep) = self.reputations.lock().unwrap().get_mut(signer_id) {
264            rep.quarantined = false;
265            rep.quarantine_until = None;
266        }
267    }
268}
269
270// === Time-Locked Threshold Signatures ===
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct TimeLockCondition {
274    pub unlock_at: DateTime<Utc>,
275}
276
277pub struct TimeLockedSession {
278    pub session_id: String,
279    pub condition: TimeLockCondition,
280    pub signature: Option<Vec<u8>>,
281    pub ready: bool,
282}
283
284impl TimeLockedSession {
285    pub fn new(session_id: &str, unlock_at: DateTime<Utc>) -> Self {
286        Self {
287            session_id: session_id.into(),
288            condition: TimeLockCondition { unlock_at },
289            signature: None,
290            ready: false,
291        }
292    }
293
294    pub fn check_unlock(&mut self) -> bool {
295        if Utc::now() >= self.condition.unlock_at {
296            self.ready = true;
297        }
298        self.ready
299    }
300
301    pub fn store_signature(&mut self, sig: Vec<u8>) -> Result<(), String> {
302        if !self.ready {
303            return Err("not unlocked yet".into());
304        }
305        self.signature = Some(sig);
306        Ok(())
307    }
308}
309
310// === Conditional Threshold Signatures ===
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub enum SigningCondition {
314    Always,
315    QuorumVote {
316        required: u32,
317    },
318    OracleValue {
319        oracle_id: String,
320        min_value: f64,
321    },
322    TimeWindow {
323        start: DateTime<Utc>,
324        end: DateTime<Utc>,
325    },
326}
327
328impl SigningCondition {
329    pub fn evaluate(&self, context: &SigningContext) -> bool {
330        match self {
331            Self::Always => true,
332            Self::QuorumVote { required } => context.vote_count >= *required as usize,
333            Self::OracleValue { min_value, .. } => {
334                context.oracle_value.unwrap_or(f64::MIN) >= *min_value
335            }
336            Self::TimeWindow { start, end } => {
337                let now = Utc::now();
338                now >= *start && now <= *end
339            }
340        }
341    }
342}
343
344#[derive(Debug, Clone, Default)]
345pub struct SigningContext {
346    pub vote_count: usize,
347    pub oracle_value: Option<f64>,
348}
349
350// === Cross-Quorum Signature Aggregation ===
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct CrossQuorumAggSig {
354    pub quorum_ids: Vec<String>,
355    pub message_hash_hex: String,
356    pub aggregate_signature_hex: String,
357    pub timestamp: DateTime<Utc>,
358}
359
360pub fn aggregate_cross_quorum(
361    quorum_sigs: &[(String, Vec<u8>)],
362    message_hash: &[u8],
363) -> CrossQuorumAggSig {
364    let mut agg = vec![0u8; 64];
365    for (_, sig) in quorum_sigs {
366        for (i, &b) in sig.iter().take(64).enumerate() {
367            agg[i] ^= b;
368        }
369    }
370    CrossQuorumAggSig {
371        quorum_ids: quorum_sigs.iter().map(|(q, _)| q.clone()).collect(),
372        message_hash_hex: hex::encode(message_hash),
373        aggregate_signature_hex: hex::encode(&agg),
374        timestamp: Utc::now(),
375    }
376}
377
378// === Configuration Hot-Reload ===
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct CoordinatorConfig {
382    pub max_sessions: usize,
383    pub session_timeout_secs: u64,
384    pub rate_limit_per_minute: u32,
385    pub allowed_schemes: Vec<String>,
386    pub config_version: u32,
387}
388
389impl Default for CoordinatorConfig {
390    fn default() -> Self {
391        Self {
392            max_sessions: 100,
393            session_timeout_secs: 3600,
394            rate_limit_per_minute: 100,
395            allowed_schemes: vec!["CMP20".into(), "FROST-P256".into()],
396            config_version: 1,
397        }
398    }
399}
400
401pub struct ConfigManager {
402    config: Mutex<CoordinatorConfig>,
403    reload_count: AtomicU64,
404    last_reload: Mutex<Option<DateTime<Utc>>>,
405}
406
407impl ConfigManager {
408    pub fn new(initial: CoordinatorConfig) -> Self {
409        Self {
410            config: Mutex::new(initial),
411            reload_count: AtomicU64::new(0),
412            last_reload: Mutex::new(None),
413        }
414    }
415
416    pub fn reload(&self, new_config: CoordinatorConfig) {
417        let mut config = self.config.lock().unwrap();
418        let old_version = config.config_version;
419        *config = CoordinatorConfig {
420            config_version: old_version + 1,
421            ..new_config
422        };
423        self.reload_count.fetch_add(1, Ordering::SeqCst);
424        *self.last_reload.lock().unwrap() = Some(Utc::now());
425    }
426
427    pub fn config(&self) -> CoordinatorConfig {
428        self.config.lock().unwrap().clone()
429    }
430    pub fn reload_count(&self) -> u64 {
431        self.reload_count.load(Ordering::SeqCst)
432    }
433    pub fn last_reload(&self) -> Option<DateTime<Utc>> {
434        *self.last_reload.lock().unwrap()
435    }
436    pub fn config_version(&self) -> u32 {
437        self.config.lock().unwrap().config_version
438    }
439}
440
441// === Wallet Policy Language ===
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct WalletPolicy {
445    pub required_signers: u32,
446    pub total_signers: u32,
447    pub max_amount: Option<u64>,
448    pub allowed_recipients: Option<HashSet<String>>,
449    pub time_window: Option<(DateTime<Utc>, DateTime<Utc>)>,
450}
451
452impl WalletPolicy {
453    pub fn evaluate(&self, ctx: &WalletTxContext) -> Result<(), String> {
454        if ctx.signer_count < self.required_signers as usize {
455            return Err(format!(
456                "need {} signers, got {}",
457                self.required_signers, ctx.signer_count
458            ));
459        }
460        if let Some(max) = self.max_amount {
461            if ctx.amount > max {
462                return Err(format!("amount {} exceeds max {}", ctx.amount, max));
463            }
464        }
465        if let Some(ref allowed) = self.allowed_recipients {
466            if !allowed.contains(&ctx.recipient) {
467                return Err(format!("recipient {} not in allowlist", ctx.recipient));
468            }
469        }
470        if let Some((start, end)) = self.time_window {
471            let now = Utc::now();
472            if now < start || now > end {
473                return Err("outside time window".into());
474            }
475        }
476        Ok(())
477    }
478}
479
480#[derive(Debug, Clone)]
481pub struct WalletTxContext {
482    pub signer_count: usize,
483    pub amount: u64,
484    pub recipient: String,
485}
486
487// === Replay Protection ===
488
489pub struct ReplayProtection {
490    seen_nonces: Mutex<HashSet<Vec<u8>>>,
491    max_cache_size: usize,
492}
493
494impl ReplayProtection {
495    pub fn new(max_cache_size: usize) -> Self {
496        Self {
497            seen_nonces: Mutex::new(HashSet::new()),
498            max_cache_size,
499        }
500    }
501
502    pub fn check_and_consume(&self, nonce: &[u8]) -> bool {
503        let mut seen = self.seen_nonces.lock().unwrap();
504        if seen.contains(nonce) {
505            return false;
506        }
507        if seen.len() >= self.max_cache_size {
508            // Evict ~10% of entries (random-ish eviction)
509            let to_remove = self.max_cache_size / 10;
510            let keys: Vec<Vec<u8>> = seen.iter().take(to_remove).cloned().collect();
511            for k in keys {
512                seen.remove(&k);
513            }
514        }
515        seen.insert(nonce.to_vec());
516        true
517    }
518
519    pub fn is_seen(&self, nonce: &[u8]) -> bool {
520        self.seen_nonces.lock().unwrap().contains(nonce)
521    }
522
523    pub fn cache_size(&self) -> usize {
524        self.seen_nonces.lock().unwrap().len()
525    }
526}
527
528// === Proactive Security Scheduler ===
529
530pub struct ProactiveScheduler {
531    last_refresh: Mutex<DateTime<Utc>>,
532    refresh_interval: Duration,
533    refresh_count: AtomicU64,
534    auto_refresh: Mutex<bool>,
535}
536
537impl ProactiveScheduler {
538    pub fn new(refresh_interval: Duration) -> Self {
539        Self {
540            last_refresh: Mutex::new(Utc::now()),
541            refresh_interval,
542            refresh_count: AtomicU64::new(0),
543            auto_refresh: Mutex::new(true),
544        }
545    }
546
547    pub fn should_refresh(&self) -> bool {
548        if !*self.auto_refresh.lock().unwrap() {
549            return false;
550        }
551        let last = *self.last_refresh.lock().unwrap();
552        Utc::now() - last >= self.refresh_interval
553    }
554
555    pub fn mark_refreshed(&self) {
556        *self.last_refresh.lock().unwrap() = Utc::now();
557        self.refresh_count.fetch_add(1, Ordering::SeqCst);
558    }
559
560    pub fn set_auto_refresh(&self, enabled: bool) {
561        *self.auto_refresh.lock().unwrap() = enabled;
562    }
563    pub fn refresh_count(&self) -> u64 {
564        self.refresh_count.load(Ordering::SeqCst)
565    }
566    pub fn last_refresh(&self) -> DateTime<Utc> {
567        *self.last_refresh.lock().unwrap()
568    }
569
570    pub fn next_refresh_at(&self) -> DateTime<Utc> {
571        *self.last_refresh.lock().unwrap() + self.refresh_interval
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    // Tamper-evident audit
580    #[test]
581    fn audit_append_and_verify() {
582        let log = TamperProofLog::new();
583        log.append("created", b"session-1");
584        log.append("signed", b"sig-data");
585        assert!(log.verify_integrity());
586        assert_eq!(log.entry_count(), 2);
587    }
588
589    #[test]
590    fn audit_detects_tampering() {
591        let log = TamperProofLog::new();
592        log.append("event", b"data1");
593        log.append("event", b"data2");
594        // Manually tamper with an entry
595        log.entries.lock().unwrap()[0].payload_hex = hex::encode(b"tampered");
596        assert!(!log.verify_integrity());
597    }
598
599    #[test]
600    fn audit_root_changes() {
601        let log = TamperProofLog::new();
602        let root1 = log.root_hash();
603        log.append("e", b"d");
604        let root2 = log.root_hash();
605        assert_ne!(root1, root2);
606    }
607
608    // Session GC
609    #[test]
610    fn gc_collects_old_completed() {
611        let gc = SessionGarbageCollector::new(GcConfig {
612            max_completed_age: Duration::seconds(0),
613            ..Default::default()
614        });
615        let mut sessions = vec![
616            GcSessionInfo {
617                session_id: "s1".into(),
618                state: "completed".into(),
619                last_updated: Utc::now() - Duration::hours(48),
620            },
621            GcSessionInfo {
622                session_id: "s2".into(),
623                state: "pending".into(),
624                last_updated: Utc::now(),
625            },
626        ];
627        let collected = gc.collect(&mut sessions);
628        assert_eq!(collected, 1);
629        assert_eq!(sessions.len(), 1);
630    }
631
632    #[test]
633    fn gc_preserves_pending() {
634        let gc = SessionGarbageCollector::new(GcConfig::default());
635        let mut sessions = vec![GcSessionInfo {
636            session_id: "s1".into(),
637            state: "pending".into(),
638            last_updated: Utc::now() - Duration::days(365),
639        }];
640        gc.collect(&mut sessions);
641        assert_eq!(sessions.len(), 1);
642    }
643
644    // Signer quarantine
645    #[test]
646    fn quarantine_after_bad_threshold() {
647        let q = SignerQuarantine::new(3, Duration::hours(1));
648        q.record_bad("s1");
649        q.record_bad("s1");
650        assert!(!q.is_quarantined("s1"));
651        q.record_bad("s1");
652        assert!(q.is_quarantined("s1"));
653    }
654
655    #[test]
656    fn quarantine_release() {
657        let q = SignerQuarantine::new(1, Duration::hours(1));
658        q.record_bad("s1");
659        assert!(q.is_quarantined("s1"));
660        q.release("s1");
661        assert!(!q.is_quarantined("s1"));
662    }
663
664    #[test]
665    fn quarantine_good_records_recover() {
666        let q = SignerQuarantine::new(1, Duration::hours(1));
667        q.record_bad("s1");
668        assert!(q.is_quarantined("s1"));
669        for _ in 0..10 {
670            q.record_good("s1");
671        }
672        assert!(!q.is_quarantined("s1"));
673    }
674
675    // Time-locked signing
676    #[test]
677    fn time_lock_not_ready_before() {
678        let mut session = TimeLockedSession::new("s1", Utc::now() + Duration::hours(1));
679        assert!(!session.check_unlock());
680        assert!(session.store_signature(vec![1]).is_err());
681    }
682
683    #[test]
684    fn time_lock_ready_after() {
685        let mut session = TimeLockedSession::new("s1", Utc::now() - Duration::seconds(1));
686        assert!(session.check_unlock());
687        assert!(session.store_signature(vec![1]).is_ok());
688    }
689
690    // Conditional signing
691    #[test]
692    fn condition_always_passes() {
693        let cond = SigningCondition::Always;
694        assert!(cond.evaluate(&SigningContext::default()));
695    }
696
697    #[test]
698    fn condition_quorum_vote() {
699        let cond = SigningCondition::QuorumVote { required: 3 };
700        assert!(!cond.evaluate(&SigningContext {
701            vote_count: 2,
702            ..Default::default()
703        }));
704        assert!(cond.evaluate(&SigningContext {
705            vote_count: 3,
706            ..Default::default()
707        }));
708    }
709
710    #[test]
711    fn condition_oracle() {
712        let cond = SigningCondition::OracleValue {
713            oracle_id: "price".into(),
714            min_value: 100.0,
715        };
716        assert!(!cond.evaluate(&SigningContext {
717            oracle_value: Some(50.0),
718            ..Default::default()
719        }));
720        assert!(cond.evaluate(&SigningContext {
721            oracle_value: Some(150.0),
722            ..Default::default()
723        }));
724    }
725
726    #[test]
727    fn condition_time_window() {
728        let now = Utc::now();
729        let cond = SigningCondition::TimeWindow {
730            start: now - Duration::hours(1),
731            end: now + Duration::hours(1),
732        };
733        assert!(cond.evaluate(&SigningContext::default()));
734    }
735
736    // Cross-quorum aggregation
737    #[test]
738    fn cross_quorum_aggregate() {
739        let sigs = vec![
740            ("q1".to_string(), vec![0xAA; 64]),
741            ("q2".to_string(), vec![0xBB; 64]),
742        ];
743        let agg = aggregate_cross_quorum(&sigs, &[0x42; 32]);
744        assert_eq!(agg.quorum_ids.len(), 2);
745        assert!(!agg.aggregate_signature_hex.is_empty());
746    }
747
748    // Config hot-reload
749    #[test]
750    fn config_reload_updates() {
751        let mgr = ConfigManager::new(CoordinatorConfig::default());
752        assert_eq!(mgr.config_version(), 1);
753        let new_config = CoordinatorConfig {
754            max_sessions: 200,
755            ..Default::default()
756        };
757        mgr.reload(new_config);
758        assert_eq!(mgr.config().max_sessions, 200);
759        assert_eq!(mgr.config_version(), 2);
760        assert_eq!(mgr.reload_count(), 1);
761    }
762
763    // Wallet policy
764    #[test]
765    fn wallet_policy_passes() {
766        let policy = WalletPolicy {
767            required_signers: 2,
768            total_signers: 3,
769            max_amount: Some(1000),
770            allowed_recipients: None,
771            time_window: None,
772        };
773        let ctx = WalletTxContext {
774            signer_count: 2,
775            amount: 500,
776            recipient: "bob".into(),
777        };
778        assert!(policy.evaluate(&ctx).is_ok());
779    }
780
781    #[test]
782    fn wallet_policy_fails_insufficient_signers() {
783        let policy = WalletPolicy {
784            required_signers: 3,
785            total_signers: 5,
786            max_amount: None,
787            allowed_recipients: None,
788            time_window: None,
789        };
790        let ctx = WalletTxContext {
791            signer_count: 2,
792            amount: 100,
793            recipient: "x".into(),
794        };
795        assert!(policy.evaluate(&ctx).is_err());
796    }
797
798    #[test]
799    fn wallet_policy_fails_exceeds_amount() {
800        let policy = WalletPolicy {
801            required_signers: 1,
802            total_signers: 1,
803            max_amount: Some(100),
804            allowed_recipients: None,
805            time_window: None,
806        };
807        let ctx = WalletTxContext {
808            signer_count: 1,
809            amount: 200,
810            recipient: "x".into(),
811        };
812        assert!(policy.evaluate(&ctx).is_err());
813    }
814
815    #[test]
816    fn wallet_policy_recipient_allowlist() {
817        let mut allowed = HashSet::new();
818        allowed.insert("alice".into());
819        let policy = WalletPolicy {
820            required_signers: 1,
821            total_signers: 1,
822            max_amount: None,
823            allowed_recipients: Some(allowed),
824            time_window: None,
825        };
826        assert!(
827            policy
828                .evaluate(&WalletTxContext {
829                    signer_count: 1,
830                    amount: 0,
831                    recipient: "alice".into()
832                })
833                .is_ok()
834        );
835        assert!(
836            policy
837                .evaluate(&WalletTxContext {
838                    signer_count: 1,
839                    amount: 0,
840                    recipient: "bob".into()
841                })
842                .is_err()
843        );
844    }
845
846    // Replay protection
847    #[test]
848    fn replay_first_use_accepted() {
849        let rp = ReplayProtection::new(1000);
850        assert!(rp.check_and_consume(b"nonce1"));
851    }
852
853    #[test]
854    fn replay_duplicate_rejected() {
855        let rp = ReplayProtection::new(1000);
856        rp.check_and_consume(b"nonce1");
857        assert!(!rp.check_and_consume(b"nonce1"));
858    }
859
860    #[test]
861    fn replay_different_nonces_accepted() {
862        let rp = ReplayProtection::new(1000);
863        assert!(rp.check_and_consume(b"n1"));
864        assert!(rp.check_and_consume(b"n2"));
865    }
866
867    #[test]
868    fn replay_cache_eviction() {
869        let rp = ReplayProtection::new(10);
870        for i in 0..15 {
871            rp.check_and_consume(&[i as u8; 8]);
872        }
873        // Old nonces should be evicted
874        assert!(rp.cache_size() <= 15);
875    }
876
877    // Proactive security
878    #[test]
879    fn proactive_should_refresh_after_interval() {
880        let scheduler = ProactiveScheduler::new(Duration::milliseconds(1));
881        std::thread::sleep(std::time::Duration::from_millis(5));
882        assert!(scheduler.should_refresh());
883    }
884
885    #[test]
886    fn proactive_not_ready_before_interval() {
887        let scheduler = ProactiveScheduler::new(Duration::hours(1));
888        assert!(!scheduler.should_refresh());
889    }
890
891    #[test]
892    fn proactive_mark_refreshed_resets() {
893        let scheduler = ProactiveScheduler::new(Duration::hours(1));
894        assert!(!scheduler.should_refresh());
895        scheduler.mark_refreshed();
896        assert_eq!(scheduler.refresh_count(), 1);
897        assert!(!scheduler.should_refresh());
898    }
899
900    #[test]
901    fn proactive_auto_refresh_toggle() {
902        let scheduler = ProactiveScheduler::new(Duration::milliseconds(1));
903        std::thread::sleep(std::time::Duration::from_millis(5));
904        scheduler.set_auto_refresh(false);
905        assert!(!scheduler.should_refresh());
906    }
907}