Skip to main content

licenz_core/
witness.rs

1//! Security Witness Pattern
2//!
3//! This module provides the Security Witness functionality - pure attestation
4//! of license state without any policy enforcement. The witness observes and
5//! reports; it does not decide or enforce.
6//!
7//! # Philosophy
8//!
9//! The Security Witness Pattern separates concerns:
10//! - **Attestation** (this module): Observe, measure, report facts
11//! - **Enforcement** (licenz-policy): Decide, enforce, act on attestations
12//!
13//! By keeping attestation in open-source core, users can audit exactly
14//! what is being measured. By keeping enforcement in a separate layer,
15//! applications can customize their response to attestations.
16
17use crate::anti_tamper::{ClockStatus, HardwareFingerprint, LicenseState};
18use crate::container::RuntimeEnvironment;
19use crate::hardware::{default_hardware_environment, HardwareEnvironment};
20use crate::verifier::LicenseVerifier;
21use crate::{LicenseError, Result, SignedLicense, StateManager};
22use chrono::{DateTime, Duration, Utc};
23use serde::{Deserialize, Serialize};
24use std::path::Path;
25use std::sync::Arc;
26
27/// Security attestation result from the witness
28///
29/// This struct contains observations about the license and system state.
30/// It does NOT make policy decisions - that's for the enforcement layer.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SecurityAttestation {
33    // ============================================
34    // Core validation results
35    // ============================================
36    /// Was the signature cryptographically valid?
37    pub signature_valid: bool,
38
39    /// Signature verification details (if failed)
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub signature_error: Option<String>,
42
43    /// Expiration observation
44    pub expiration: ExpirationAttestation,
45
46    /// Hardware binding observation
47    pub hardware: HardwareAttestation,
48
49    // ============================================
50    // State observations
51    // ============================================
52    /// Clock/time observations
53    pub clock: ClockAttestation,
54
55    /// State file observations
56    pub state_files: StateFileAttestation,
57
58    /// Environment observations
59    pub environment: EnvironmentAttestation,
60
61    // ============================================
62    // Anomalies detected
63    // ============================================
64    /// List of anomalies observed (neutral - no judgment on severity)
65    pub anomalies: Vec<SecurityAnomaly>,
66
67    // ============================================
68    // Metadata
69    // ============================================
70    /// When this attestation was created
71    pub attested_at: DateTime<Utc>,
72
73    /// Overall validity (signature AND expiration AND hardware all pass)
74    /// Note: This is a factual observation, not a policy decision
75    pub is_valid: bool,
76}
77
78/// Expiration attestation - factual observation about time validity
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ExpirationAttestation {
81    /// Is the current time within the valid window?
82    pub is_within_window: bool,
83
84    /// Valid from timestamp
85    pub valid_from: DateTime<Utc>,
86
87    /// Valid until timestamp
88    pub valid_until: DateTime<Utc>,
89
90    /// Days until expiration (negative if expired)
91    pub days_remaining: i64,
92
93    /// If not valid, why?
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub issue: Option<ExpirationIssue>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub enum ExpirationIssue {
100    NotYetValid { starts_in_days: i64 },
101    Expired { expired_days_ago: i64 },
102}
103
104/// Hardware attestation - factual observation about hardware match
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct HardwareAttestation {
107    /// Was hardware binding checked?
108    pub was_checked: bool,
109
110    /// Match percentage (0.0 - 100.0)
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub match_percentage: Option<f32>,
113
114    /// Which factors matched
115    pub matched_factors: Vec<String>,
116
117    /// Which factors did not match
118    pub unmatched_factors: Vec<String>,
119
120    /// Current hardware fingerprint (for debugging)
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub current_fingerprint: Option<HardwareFingerprint>,
123}
124
125/// Clock attestation - factual observation about system time
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ClockAttestation {
128    /// Current system time at attestation
129    pub current_time: DateTime<Utc>,
130
131    /// Last known system time (from state)
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub last_seen_time: Option<DateTime<Utc>>,
134
135    /// Drift from last seen time (positive = forward, negative = backward)
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub drift_seconds: Option<i64>,
138
139    /// Clock status observation
140    pub status: ClockStatusAttestation,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub enum ClockStatusAttestation {
145    /// Clock appears normal
146    Normal,
147    /// Clock moved backward
148    DriftedBackward { seconds: i64 },
149    /// Clock jumped forward significantly
150    JumpedForward { seconds: i64 },
151    /// No previous state to compare
152    NoPreviousState,
153    /// Error checking clock
154    CheckFailed { reason: String },
155}
156
157/// State file attestation - factual observation about state files
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct StateFileAttestation {
160    /// Number of state file locations checked
161    pub locations_checked: usize,
162
163    /// Number of valid state files found
164    pub valid_files_found: usize,
165
166    /// Number of corrupted/tampered files found
167    pub corrupted_files_found: usize,
168
169    /// Number of missing files
170    pub missing_files: usize,
171
172    /// State file observations
173    pub observations: Vec<StateFileObservation>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct StateFileObservation {
178    pub location: String,
179    pub status: StateFileStatus,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub enum StateFileStatus {
184    Valid,
185    Missing,
186    Corrupted,
187    Tampered,
188    ReadError { reason: String },
189}
190
191/// Environment attestation - factual observation about runtime environment
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct EnvironmentAttestation {
194    /// Detected runtime environment
195    pub runtime: RuntimeEnvironment,
196
197    /// Is this a containerized environment?
198    pub is_containerized: bool,
199
200    /// Is this a virtualized environment?
201    pub is_virtualized: bool,
202
203    /// Cloud provider (if detected)
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub cloud_provider: Option<String>,
206}
207
208/// Security anomaly - an observation that something unusual was detected
209///
210/// Note: These are neutral observations. Whether an anomaly is "bad" or
211/// requires action is a POLICY decision, not an attestation decision.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub enum SecurityAnomaly {
214    /// Clock moved backward
215    ClockMovedBackward {
216        drift_seconds: i64,
217        last_seen: DateTime<Utc>,
218        current: DateTime<Utc>,
219    },
220
221    /// Clock jumped forward significantly
222    ClockJumpedForward {
223        jump_seconds: i64,
224        last_seen: DateTime<Utc>,
225        current: DateTime<Utc>,
226    },
227
228    /// State file was missing
229    StateFileMissing { location: String },
230
231    /// State file was corrupted
232    StateFileCorrupted { location: String },
233
234    /// State file appeared tampered (checksum mismatch)
235    StateFileTampered { location: String },
236
237    /// State files inconsistent (different validation counts)
238    StateFilesInconsistent {
239        highest_count: u64,
240        lowest_count: u64,
241    },
242
243    /// Hardware fingerprint partially changed
244    HardwareFingerprintChanged {
245        changed_factors: Vec<String>,
246        unchanged_factors: Vec<String>,
247    },
248
249    /// Running in a virtual machine
250    VirtualMachineDetected { hypervisor: Option<String> },
251
252    /// Running in a container
253    ContainerDetected { runtime: String },
254
255    /// Debugger or instrumentation detected
256    DebuggerDetected { method: String },
257
258    /// License file was modified (caught by signature)
259    LicenseModified,
260
261    /// Custom anomaly for extensibility
262    Custom { code: String, description: String },
263}
264
265/// Configuration for the security witness
266#[derive(Clone)]
267pub struct WitnessConfig {
268    /// Check hardware binding
269    pub check_hardware: bool,
270
271    /// Check clock consistency (requires [`Self::state_integrity_key`] when true)
272    pub check_clock: bool,
273
274    /// Check state files (requires [`Self::state_integrity_key`] when true)
275    pub check_state_files: bool,
276
277    /// Detect virtualization
278    pub detect_virtualization: bool,
279
280    /// Detect containers
281    pub detect_containers: bool,
282
283    /// Tolerance for clock drift detection
284    pub clock_tolerance: Duration,
285
286    /// HMAC key for [`LicenseState`] at rest; required when `check_clock` or `check_state_files` is true.
287    pub state_integrity_key: Option<[u8; 32]>,
288
289    /// Source of [`crate::hardware::HardwareInfo`] for binding attestation (default: OS probe).
290    pub hardware_environment: Arc<dyn HardwareEnvironment>,
291}
292
293impl std::fmt::Debug for WitnessConfig {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        f.debug_struct("WitnessConfig")
296            .field("check_hardware", &self.check_hardware)
297            .field("check_clock", &self.check_clock)
298            .field("check_state_files", &self.check_state_files)
299            .field("detect_virtualization", &self.detect_virtualization)
300            .field("detect_containers", &self.detect_containers)
301            .field("clock_tolerance", &self.clock_tolerance)
302            .field(
303                "state_integrity_key",
304                &self.state_integrity_key.as_ref().map(|_| "<redacted>"),
305            )
306            .field("hardware_environment", &"<dyn HardwareEnvironment>")
307            .finish()
308    }
309}
310
311impl Default for WitnessConfig {
312    fn default() -> Self {
313        Self {
314            check_hardware: true,
315            check_clock: false,
316            check_state_files: false,
317            detect_virtualization: true,
318            detect_containers: true,
319            clock_tolerance: Duration::hours(1),
320            state_integrity_key: None,
321            hardware_environment: default_hardware_environment(),
322        }
323    }
324}
325
326/// The Security Witness - performs attestation without enforcement
327pub struct SecurityWitness {
328    verifier: LicenseVerifier,
329}
330
331impl SecurityWitness {
332    /// Create a new security witness from a public key PEM string
333    pub fn new(public_key_pem: &str) -> Result<Self> {
334        let verifier = LicenseVerifier::from_pem(public_key_pem)?;
335        Ok(Self { verifier })
336    }
337
338    /// Create from a PEM file path
339    pub fn from_pem_file(path: &Path) -> Result<Self> {
340        let verifier = LicenseVerifier::from_pem_file(path)?;
341        Ok(Self { verifier })
342    }
343
344    /// Perform comprehensive attestation of a license
345    pub fn attest(
346        &self,
347        license_path: impl AsRef<Path>,
348        config: &WitnessConfig,
349    ) -> Result<SecurityAttestation> {
350        let license = self.verifier.load_license(license_path.as_ref())?;
351        self.attest_license(&license, config)
352    }
353
354    /// Attest an already-loaded license
355    pub fn attest_license(
356        &self,
357        license: &SignedLicense,
358        config: &WitnessConfig,
359    ) -> Result<SecurityAttestation> {
360        if (config.check_state_files || config.check_clock) && config.state_integrity_key.is_none()
361        {
362            return Err(LicenseError::Validation(
363                "WitnessConfig: check_clock and check_state_files require state_integrity_key"
364                    .into(),
365            ));
366        }
367
368        let mut anomalies = Vec::new();
369        let now = Utc::now();
370
371        // 1. Verify signature
372        let (signature_valid, signature_error) = match self.verifier.verify_signature(license) {
373            Ok(_) => (true, None),
374            Err(e) => {
375                anomalies.push(SecurityAnomaly::LicenseModified);
376                (false, Some(e.to_string()))
377            }
378        };
379
380        // 2. Check expiration
381        let expiration = self.attest_expiration(license, now);
382
383        // 3. Check hardware
384        let hardware = if config.check_hardware {
385            self.attest_hardware(license, config, &mut anomalies)
386        } else {
387            HardwareAttestation {
388                was_checked: false,
389                match_percentage: None,
390                matched_factors: Vec::new(),
391                unmatched_factors: Vec::new(),
392                current_fingerprint: None,
393            }
394        };
395
396        // 4. Check clock
397        let clock = if config.check_clock {
398            self.attest_clock(&license.data.id, config, &mut anomalies)
399        } else {
400            ClockAttestation {
401                current_time: now,
402                last_seen_time: None,
403                drift_seconds: None,
404                status: ClockStatusAttestation::NoPreviousState,
405            }
406        };
407
408        // 5. Check state files
409        let state_files = if config.check_state_files {
410            self.attest_state_files(&license.data.id, config, &mut anomalies)
411        } else {
412            StateFileAttestation {
413                locations_checked: 0,
414                valid_files_found: 0,
415                corrupted_files_found: 0,
416                missing_files: 0,
417                observations: Vec::new(),
418            }
419        };
420
421        // 6. Check environment
422        let environment = self.attest_environment(config, &mut anomalies);
423
424        // Calculate overall validity (factual, not policy)
425        let is_valid = signature_valid
426            && expiration.is_within_window
427            && (!hardware.was_checked
428                || hardware
429                    .matched_factors
430                    .len()
431                    .saturating_add(hardware.unmatched_factors.len())
432                    == 0
433                || !hardware.matched_factors.is_empty());
434
435        Ok(SecurityAttestation {
436            signature_valid,
437            signature_error,
438            expiration,
439            hardware,
440            clock,
441            state_files,
442            environment,
443            anomalies,
444            attested_at: now,
445            is_valid,
446        })
447    }
448
449    fn attest_expiration(
450        &self,
451        license: &SignedLicense,
452        now: DateTime<Utc>,
453    ) -> ExpirationAttestation {
454        let days_remaining = license.data.days_remaining();
455        let is_within_window = now >= license.data.valid_from && now <= license.data.valid_until;
456
457        let issue = if now < license.data.valid_from {
458            Some(ExpirationIssue::NotYetValid {
459                starts_in_days: (license.data.valid_from - now).num_days(),
460            })
461        } else if now > license.data.valid_until {
462            Some(ExpirationIssue::Expired {
463                expired_days_ago: (now - license.data.valid_until).num_days(),
464            })
465        } else {
466            None
467        };
468
469        ExpirationAttestation {
470            is_within_window,
471            valid_from: license.data.valid_from,
472            valid_until: license.data.valid_until,
473            days_remaining,
474            issue,
475        }
476    }
477
478    fn attest_hardware(
479        &self,
480        license: &SignedLicense,
481        config: &WitnessConfig,
482        anomalies: &mut Vec<SecurityAnomaly>,
483    ) -> HardwareAttestation {
484        let current_hw = config.hardware_environment.snapshot();
485        let binding = &license.data.hardware_binding;
486
487        let mut matched_factors = Vec::new();
488        let mut unmatched_factors = Vec::new();
489
490        // Check MAC addresses
491        if !binding.mac_addresses.is_empty() {
492            let current_macs: Vec<String> = current_hw
493                .mac_addresses
494                .iter()
495                .map(|m| m.to_uppercase())
496                .collect();
497
498            let has_match = binding
499                .mac_addresses
500                .iter()
501                .any(|bound| current_macs.contains(&bound.to_uppercase()));
502
503            if has_match {
504                matched_factors.push("mac_address".to_string());
505            } else {
506                unmatched_factors.push("mac_address".to_string());
507            }
508        }
509
510        // Check hostnames
511        if !binding.hostnames.is_empty() {
512            if let Some(ref hostname) = current_hw.hostname {
513                let has_match = binding
514                    .hostnames
515                    .iter()
516                    .any(|bound| bound.eq_ignore_ascii_case(hostname));
517
518                if has_match {
519                    matched_factors.push("hostname".to_string());
520                } else {
521                    unmatched_factors.push("hostname".to_string());
522                }
523            } else {
524                unmatched_factors.push("hostname".to_string());
525            }
526        }
527
528        // Check disk IDs
529        if !binding.disk_ids.is_empty() {
530            let has_match = binding
531                .disk_ids
532                .iter()
533                .any(|bound| current_hw.disk_ids.contains(bound));
534
535            if has_match {
536                matched_factors.push("disk_id".to_string());
537            } else {
538                unmatched_factors.push("disk_id".to_string());
539            }
540        }
541
542        // Check machine ID (custom binding)
543        if let Some(expected_ids) = binding.custom.get("machine_id") {
544            if let Some(ref current_id) = current_hw.machine_id {
545                if expected_ids.contains(current_id) {
546                    matched_factors.push("machine_id".to_string());
547                } else {
548                    unmatched_factors.push("machine_id".to_string());
549                }
550            } else {
551                unmatched_factors.push("machine_id".to_string());
552            }
553        }
554
555        // Record anomaly if hardware changed
556        if !unmatched_factors.is_empty() && !matched_factors.is_empty() {
557            anomalies.push(SecurityAnomaly::HardwareFingerprintChanged {
558                changed_factors: unmatched_factors.clone(),
559                unchanged_factors: matched_factors.clone(),
560            });
561        }
562
563        // Calculate match percentage
564        let total_factors = matched_factors.len() + unmatched_factors.len();
565        let match_percentage = if total_factors > 0 {
566            Some((matched_factors.len() as f32 / total_factors as f32) * 100.0)
567        } else {
568            None
569        };
570
571        HardwareAttestation {
572            was_checked: true,
573            match_percentage,
574            matched_factors,
575            unmatched_factors,
576            current_fingerprint: Some(HardwareFingerprint::generate_with(
577                config.hardware_environment.as_ref(),
578            )),
579        }
580    }
581
582    fn attest_clock(
583        &self,
584        license_id: &str,
585        config: &WitnessConfig,
586        anomalies: &mut Vec<SecurityAnomaly>,
587    ) -> ClockAttestation {
588        let now = Utc::now();
589        let key = config
590            .state_integrity_key
591            .expect("validated at attest_license");
592        let state_manager = StateManager::new(license_id, key);
593
594        match state_manager.load(license_id) {
595            Ok(Some(state)) => {
596                let drift_seconds = (now - state.last_system_time).num_seconds();
597
598                let status = match state.detect_clock_manipulation(config.clock_tolerance) {
599                    Ok(ClockStatus::Ok { .. }) => ClockStatusAttestation::Normal,
600                    Ok(ClockStatus::Backwards {
601                        drift,
602                        last_seen,
603                        current,
604                    }) => {
605                        anomalies.push(SecurityAnomaly::ClockMovedBackward {
606                            drift_seconds: drift.num_seconds(),
607                            last_seen,
608                            current,
609                        });
610                        ClockStatusAttestation::DriftedBackward {
611                            seconds: drift.num_seconds(),
612                        }
613                    }
614                    Ok(ClockStatus::SuspiciousJump {
615                        jump,
616                        last_seen,
617                        current,
618                    }) => {
619                        anomalies.push(SecurityAnomaly::ClockJumpedForward {
620                            jump_seconds: jump.num_seconds(),
621                            last_seen,
622                            current,
623                        });
624                        ClockStatusAttestation::JumpedForward {
625                            seconds: jump.num_seconds(),
626                        }
627                    }
628                    Err(e) => ClockStatusAttestation::CheckFailed {
629                        reason: e.to_string(),
630                    },
631                };
632
633                ClockAttestation {
634                    current_time: now,
635                    last_seen_time: Some(state.last_system_time),
636                    drift_seconds: Some(drift_seconds),
637                    status,
638                }
639            }
640            Ok(None) => ClockAttestation {
641                current_time: now,
642                last_seen_time: None,
643                drift_seconds: None,
644                status: ClockStatusAttestation::NoPreviousState,
645            },
646            Err(e) => ClockAttestation {
647                current_time: now,
648                last_seen_time: None,
649                drift_seconds: None,
650                status: ClockStatusAttestation::CheckFailed {
651                    reason: e.to_string(),
652                },
653            },
654        }
655    }
656
657    fn attest_state_files(
658        &self,
659        license_id: &str,
660        config: &WitnessConfig,
661        anomalies: &mut Vec<SecurityAnomaly>,
662    ) -> StateFileAttestation {
663        let key = config
664            .state_integrity_key
665            .expect("validated at attest_license");
666        let state_manager = StateManager::new(license_id, key);
667        let paths = state_manager.paths();
668
669        let mut observations = Vec::new();
670        let mut valid_count = 0;
671        let mut corrupted_count = 0;
672        let mut missing_count = 0;
673
674        for path in paths {
675            let location = path.display().to_string();
676
677            if !path.exists() {
678                missing_count += 1;
679                anomalies.push(SecurityAnomaly::StateFileMissing {
680                    location: location.clone(),
681                });
682                observations.push(StateFileObservation {
683                    location,
684                    status: StateFileStatus::Missing,
685                });
686            } else {
687                match LicenseState::load(path, license_id, &key) {
688                    Ok(Some(_)) => {
689                        valid_count += 1;
690                        observations.push(StateFileObservation {
691                            location,
692                            status: StateFileStatus::Valid,
693                        });
694                    }
695                    Ok(None) => {
696                        missing_count += 1;
697                        observations.push(StateFileObservation {
698                            location,
699                            status: StateFileStatus::Missing,
700                        });
701                    }
702                    Err(LicenseError::StateFileTampered) => {
703                        corrupted_count += 1;
704                        anomalies.push(SecurityAnomaly::StateFileTampered {
705                            location: location.clone(),
706                        });
707                        observations.push(StateFileObservation {
708                            location,
709                            status: StateFileStatus::Tampered,
710                        });
711                    }
712                    Err(e) => {
713                        corrupted_count += 1;
714                        anomalies.push(SecurityAnomaly::StateFileCorrupted {
715                            location: location.clone(),
716                        });
717                        observations.push(StateFileObservation {
718                            location,
719                            status: StateFileStatus::ReadError {
720                                reason: e.to_string(),
721                            },
722                        });
723                    }
724                }
725            }
726        }
727
728        StateFileAttestation {
729            locations_checked: paths.len(),
730            valid_files_found: valid_count,
731            corrupted_files_found: corrupted_count,
732            missing_files: missing_count,
733            observations,
734        }
735    }
736
737    fn attest_environment(
738        &self,
739        config: &WitnessConfig,
740        anomalies: &mut Vec<SecurityAnomaly>,
741    ) -> EnvironmentAttestation {
742        let runtime = RuntimeEnvironment::detect();
743
744        let is_containerized = matches!(
745            runtime,
746            RuntimeEnvironment::Docker | RuntimeEnvironment::Kubernetes
747        );
748
749        let is_virtualized = matches!(
750            runtime,
751            RuntimeEnvironment::AwsEc2
752                | RuntimeEnvironment::GcpCompute
753                | RuntimeEnvironment::AzureVm
754                | RuntimeEnvironment::GenericCloud
755        );
756
757        let cloud_provider = match runtime {
758            RuntimeEnvironment::AwsEc2 => Some("AWS".to_string()),
759            RuntimeEnvironment::GcpCompute => Some("GCP".to_string()),
760            RuntimeEnvironment::AzureVm => Some("Azure".to_string()),
761            _ => None,
762        };
763
764        // Record anomalies for detection
765        if config.detect_containers && is_containerized {
766            anomalies.push(SecurityAnomaly::ContainerDetected {
767                runtime: format!("{:?}", runtime),
768            });
769        }
770
771        if config.detect_virtualization && is_virtualized {
772            anomalies.push(SecurityAnomaly::VirtualMachineDetected {
773                hypervisor: cloud_provider.clone(),
774            });
775        }
776
777        EnvironmentAttestation {
778            runtime,
779            is_containerized,
780            is_virtualized,
781            cloud_provider,
782        }
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use crate::{KeyPair, KeySize, LicenseData, LicenseGenerator};
790
791    fn create_test_license() -> (String, SignedLicense) {
792        let keypair = KeyPair::generate(KeySize::Bits2048).unwrap();
793        let generator = LicenseGenerator::new(keypair.private_key().clone());
794
795        let data = LicenseData::builder()
796            .id("TEST-001")
797            .serial("SN-12345")
798            .customer_id("Test Customer")
799            .product_id("TestApp")
800            .valid_days(365)
801            .feature("basic")
802            .build()
803            .unwrap();
804
805        let signed = generator.generate(data).unwrap();
806        let public_key = keypair.export_public_pem().unwrap();
807
808        (public_key, signed)
809    }
810
811    #[test]
812    fn test_witness_attestation() {
813        let (public_key, license) = create_test_license();
814
815        let witness = SecurityWitness::new(&public_key).unwrap();
816        let attestation = witness
817            .attest_license(&license, &WitnessConfig::default())
818            .unwrap();
819
820        assert!(attestation.signature_valid);
821        assert!(attestation.expiration.is_within_window);
822        assert!(attestation.is_valid);
823    }
824
825    #[test]
826    fn test_attestation_is_informational() {
827        let (public_key, license) = create_test_license();
828
829        let witness = SecurityWitness::new(&public_key).unwrap();
830        let attestation = witness
831            .attest_license(&license, &WitnessConfig::default())
832            .unwrap();
833
834        // Attestation provides facts, not decisions
835        // The match_percentage is a measurement, not a pass/fail
836        // The anomalies list observations, not judgments
837        assert!(attestation.signature_valid); // Fact: signature matches
838                                              // Days remaining might be 364 or 365 depending on exact timing
839        assert!(
840            attestation.expiration.days_remaining >= 364
841                && attestation.expiration.days_remaining <= 365
842        );
843    }
844}