1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SecurityAttestation {
33 pub signature_valid: bool,
38
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub signature_error: Option<String>,
42
43 pub expiration: ExpirationAttestation,
45
46 pub hardware: HardwareAttestation,
48
49 pub clock: ClockAttestation,
54
55 pub state_files: StateFileAttestation,
57
58 pub environment: EnvironmentAttestation,
60
61 pub anomalies: Vec<SecurityAnomaly>,
66
67 pub attested_at: DateTime<Utc>,
72
73 pub is_valid: bool,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ExpirationAttestation {
81 pub is_within_window: bool,
83
84 pub valid_from: DateTime<Utc>,
86
87 pub valid_until: DateTime<Utc>,
89
90 pub days_remaining: i64,
92
93 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct HardwareAttestation {
107 pub was_checked: bool,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub match_percentage: Option<f32>,
113
114 pub matched_factors: Vec<String>,
116
117 pub unmatched_factors: Vec<String>,
119
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub current_fingerprint: Option<HardwareFingerprint>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ClockAttestation {
128 pub current_time: DateTime<Utc>,
130
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub last_seen_time: Option<DateTime<Utc>>,
134
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub drift_seconds: Option<i64>,
138
139 pub status: ClockStatusAttestation,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub enum ClockStatusAttestation {
145 Normal,
147 DriftedBackward { seconds: i64 },
149 JumpedForward { seconds: i64 },
151 NoPreviousState,
153 CheckFailed { reason: String },
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct StateFileAttestation {
160 pub locations_checked: usize,
162
163 pub valid_files_found: usize,
165
166 pub corrupted_files_found: usize,
168
169 pub missing_files: usize,
171
172 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#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct EnvironmentAttestation {
194 pub runtime: RuntimeEnvironment,
196
197 pub is_containerized: bool,
199
200 pub is_virtualized: bool,
202
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub cloud_provider: Option<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
213pub enum SecurityAnomaly {
214 ClockMovedBackward {
216 drift_seconds: i64,
217 last_seen: DateTime<Utc>,
218 current: DateTime<Utc>,
219 },
220
221 ClockJumpedForward {
223 jump_seconds: i64,
224 last_seen: DateTime<Utc>,
225 current: DateTime<Utc>,
226 },
227
228 StateFileMissing { location: String },
230
231 StateFileCorrupted { location: String },
233
234 StateFileTampered { location: String },
236
237 StateFilesInconsistent {
239 highest_count: u64,
240 lowest_count: u64,
241 },
242
243 HardwareFingerprintChanged {
245 changed_factors: Vec<String>,
246 unchanged_factors: Vec<String>,
247 },
248
249 VirtualMachineDetected { hypervisor: Option<String> },
251
252 ContainerDetected { runtime: String },
254
255 DebuggerDetected { method: String },
257
258 LicenseModified,
260
261 Custom { code: String, description: String },
263}
264
265#[derive(Clone)]
267pub struct WitnessConfig {
268 pub check_hardware: bool,
270
271 pub check_clock: bool,
273
274 pub check_state_files: bool,
276
277 pub detect_virtualization: bool,
279
280 pub detect_containers: bool,
282
283 pub clock_tolerance: Duration,
285
286 pub state_integrity_key: Option<[u8; 32]>,
288
289 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
326pub struct SecurityWitness {
328 verifier: LicenseVerifier,
329}
330
331impl SecurityWitness {
332 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 pub fn from_pem_file(path: &Path) -> Result<Self> {
340 let verifier = LicenseVerifier::from_pem_file(path)?;
341 Ok(Self { verifier })
342 }
343
344 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 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 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 let expiration = self.attest_expiration(license, now);
382
383 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 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 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 let environment = self.attest_environment(config, &mut anomalies);
423
424 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 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 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 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 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 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 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 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 assert!(attestation.signature_valid); assert!(
840 attestation.expiration.days_remaining >= 364
841 && attestation.expiration.days_remaining <= 365
842 );
843 }
844}