Skip to main content

licenz_core/
support_bundle.rs

1//! Support Bundle Generation
2//!
3//! This module provides functionality to generate diagnostic support bundles
4//! for troubleshooting license-related issues, particularly useful in air-gapped
5//! environments where direct support access isn't possible.
6//!
7//! # Overview
8//!
9//! A support bundle collects system and license state information that helps
10//! diagnose licensing problems without exposing sensitive data. The bundle can
11//! be optionally encrypted using a hardware-derived key for secure transmission.
12//!
13//! # Security
14//!
15//! - All sensitive data is sanitized or hashed before inclusion
16//! - MAC addresses, disk IDs, and hostnames are shown as partial/hashed values
17//! - License keys and signatures are never included in plain text
18//! - Optional encryption provides additional protection during transmission
19
20use crate::anti_tamper::{ClockStatus, HardwareFingerprint, LicenseState};
21use crate::container::RuntimeEnvironment;
22use crate::error::{LicenseError, Result};
23use crate::hardware::{detect_hardware, HardwareInfo};
24use crate::state_manager::StateManager;
25use chrono::{DateTime, Duration, Utc};
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28use std::collections::HashMap;
29use std::env;
30use std::path::Path;
31
32/// Support bundle format version
33pub const BUNDLE_VERSION: u8 = 1;
34
35/// Magic bytes for encrypted bundle format
36pub const ENCRYPTED_BUNDLE_MAGIC: &[u8; 4] = b"LSBX";
37
38/// A diagnostic support bundle containing system and license state information
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SupportBundle {
41    /// Bundle format version
42    pub version: u8,
43
44    /// When this bundle was generated
45    pub generated_at: DateTime<Utc>,
46
47    /// Hardware fingerprint information (sanitized)
48    pub hardware: HardwareSummary,
49
50    /// System clock state
51    pub clock_state: ClockState,
52
53    /// License status summary
54    pub license_status: Option<LicenseStatusSummary>,
55
56    /// Recent verification events/errors
57    pub verification_log: Vec<VerificationEvent>,
58
59    /// Environment information
60    pub environment: EnvironmentInfo,
61
62    /// State file observations
63    pub state_files: StateFileSummary,
64
65    /// Bundle metadata
66    pub metadata: HashMap<String, String>,
67}
68
69/// Sanitized hardware information summary
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct HardwareSummary {
72    /// Partial MAC addresses (first 3 octets shown, rest masked)
73    pub mac_addresses_partial: Vec<String>,
74
75    /// Number of detected MAC addresses
76    pub mac_count: usize,
77
78    /// Partial hostname (first 4 chars + hash suffix)
79    pub hostname_partial: Option<String>,
80
81    /// Number of detected disk IDs
82    pub disk_count: usize,
83
84    /// Partial disk identifiers (hashed)
85    pub disk_ids_hashed: Vec<String>,
86
87    /// Machine ID present (yes/no, not actual value)
88    pub has_machine_id: bool,
89
90    /// Combined fingerprint hash (for matching purposes)
91    pub fingerprint_hash: String,
92}
93
94/// System clock state information
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ClockState {
97    /// Current system time
98    pub current_time: DateTime<Utc>,
99
100    /// Last recorded validation time (from state)
101    pub last_validation_time: Option<DateTime<Utc>>,
102
103    /// Time drift detected (if any)
104    pub drift_seconds: Option<i64>,
105
106    /// Clock status assessment
107    pub status: ClockStatusSummary,
108
109    /// System timezone (from environment)
110    pub timezone: Option<String>,
111}
112
113/// Summary of clock status
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub enum ClockStatusSummary {
116    /// Clock appears normal
117    Normal,
118    /// Clock drifted backward (potential manipulation)
119    DriftedBackward { seconds: i64 },
120    /// Clock jumped forward significantly
121    JumpedForward { seconds: i64 },
122    /// No previous state available for comparison
123    NoPreviousState,
124    /// Unable to determine clock status
125    Unknown { reason: String },
126}
127
128/// License status summary (no sensitive data)
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct LicenseStatusSummary {
131    /// License ID hash (not the actual ID)
132    pub id_hash: String,
133
134    /// Product ID (non-sensitive)
135    pub product_id: String,
136
137    /// Whether the license is currently valid
138    pub is_valid: bool,
139
140    /// Days until expiration (negative if expired)
141    pub days_remaining: i64,
142
143    /// Expiration date
144    pub expires_at: DateTime<Utc>,
145
146    /// Issue date
147    pub issued_at: DateTime<Utc>,
148
149    /// Whether hardware binding is configured
150    pub has_hardware_binding: bool,
151
152    /// Hardware binding match status
153    pub hardware_match: Option<HardwareMatchStatus>,
154
155    /// Number of features enabled
156    pub feature_count: usize,
157
158    /// Validation count from state
159    pub validation_count: Option<u64>,
160
161    /// Signature algorithm used
162    pub algorithm: String,
163}
164
165/// Hardware binding match status
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct HardwareMatchStatus {
168    /// Match percentage (0-100)
169    pub percentage: f32,
170
171    /// Factors that matched
172    pub matched_factors: Vec<String>,
173
174    /// Factors that did not match
175    pub unmatched_factors: Vec<String>,
176}
177
178/// A verification event/error for the log
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct VerificationEvent {
181    /// When this event occurred
182    pub timestamp: DateTime<Utc>,
183
184    /// Event type
185    pub event_type: VerificationEventType,
186
187    /// Event message
188    pub message: String,
189
190    /// Additional context
191    #[serde(skip_serializing_if = "HashMap::is_empty")]
192    pub context: HashMap<String, String>,
193}
194
195/// Types of verification events
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub enum VerificationEventType {
198    /// Successful verification
199    Success,
200    /// Signature verification failed
201    SignatureFailure,
202    /// License expired
203    Expired,
204    /// License not yet valid
205    NotYetValid,
206    /// Hardware binding mismatch
207    HardwareMismatch,
208    /// Clock manipulation detected
209    ClockManipulation,
210    /// State file issue
211    StateFileIssue,
212    /// General error
213    Error,
214}
215
216/// Environment information
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct EnvironmentInfo {
219    /// Operating system name
220    pub os_name: String,
221
222    /// Operating system version
223    pub os_version: String,
224
225    /// CPU architecture
226    pub architecture: String,
227
228    /// Runtime environment detection
229    pub runtime: RuntimeEnvironmentSummary,
230
231    /// Is this a containerized environment?
232    pub is_containerized: bool,
233
234    /// Is this a virtualized environment?
235    pub is_virtualized: bool,
236
237    /// Detected cloud provider (if any)
238    pub cloud_provider: Option<String>,
239
240    /// Licenz library version
241    pub licenz_version: String,
242}
243
244/// Runtime environment summary
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub enum RuntimeEnvironmentSummary {
247    /// Native/bare metal
248    Native,
249    /// Docker container
250    Docker,
251    /// Kubernetes pod
252    Kubernetes,
253    /// AWS EC2 instance
254    AwsEc2,
255    /// Google Compute Engine
256    GcpCompute,
257    /// Azure VM
258    AzureVm,
259    /// Generic cloud/virtual
260    GenericCloud,
261    /// Unknown
262    Unknown,
263}
264
265/// State file summary
266#[derive(Debug, Clone, Serialize, Deserialize, Default)]
267pub struct StateFileSummary {
268    /// Number of state file locations checked
269    pub locations_checked: usize,
270
271    /// Number of valid state files found
272    pub valid_files: usize,
273
274    /// Number of corrupted/missing files
275    pub corrupted_or_missing: usize,
276
277    /// State file locations (paths only, not contents)
278    pub locations: Vec<StateFileLocation>,
279}
280
281/// State file location info
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct StateFileLocation {
284    /// Path (may be sanitized)
285    pub path: String,
286
287    /// Status of this location
288    pub status: StateFileLocationStatus,
289}
290
291/// Status of a state file location
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub enum StateFileLocationStatus {
294    /// Valid state file exists
295    Valid,
296    /// File is missing
297    Missing,
298    /// File exists but is corrupted
299    Corrupted,
300    /// File appears tampered with
301    Tampered,
302    /// Unable to read/access
303    Inaccessible { reason: String },
304}
305
306impl SupportBundle {
307    /// Generate a new support bundle with basic system information
308    pub fn generate() -> Self {
309        let now = Utc::now();
310
311        Self {
312            version: BUNDLE_VERSION,
313            generated_at: now,
314            hardware: HardwareSummary::collect(),
315            clock_state: ClockState::collect(None),
316            license_status: None,
317            verification_log: Vec::new(),
318            environment: EnvironmentInfo::collect(),
319            state_files: StateFileSummary::default(),
320            metadata: HashMap::new(),
321        }
322    }
323
324    /// Generate a support bundle with license information (`state_integrity_key` for [`LicenseState`] files).
325    pub fn generate_with_license(license_id: &str, state_integrity_key: &[u8; 32]) -> Self {
326        let now = Utc::now();
327        let state_manager = StateManager::new(license_id, *state_integrity_key);
328
329        // Load state if available
330        let state = state_manager.load(license_id).ok().flatten();
331
332        Self {
333            version: BUNDLE_VERSION,
334            generated_at: now,
335            hardware: HardwareSummary::collect(),
336            clock_state: ClockState::collect(state.as_ref()),
337            license_status: None, // Will be filled in by caller with license data
338            verification_log: Vec::new(),
339            environment: EnvironmentInfo::collect(),
340            state_files: StateFileSummary::collect(license_id, state_integrity_key),
341            metadata: HashMap::new(),
342        }
343    }
344
345    /// Add a verification event to the log
346    pub fn add_event(&mut self, event: VerificationEvent) {
347        self.verification_log.push(event);
348    }
349
350    /// Add metadata to the bundle
351    pub fn add_metadata(&mut self, key: impl Into<String>, value: impl Into<String>) {
352        self.metadata.insert(key.into(), value.into());
353    }
354
355    /// Set license status summary
356    pub fn set_license_status(&mut self, status: LicenseStatusSummary) {
357        self.license_status = Some(status);
358    }
359
360    /// Serialize the bundle to JSON
361    pub fn to_json(&self) -> Result<String> {
362        serde_json::to_string_pretty(self)
363            .map_err(|e| LicenseError::SerializationError(e.to_string()))
364    }
365
366    /// Serialize the bundle to compact JSON
367    pub fn to_json_compact(&self) -> Result<String> {
368        serde_json::to_string(self).map_err(|e| LicenseError::SerializationError(e.to_string()))
369    }
370
371    /// Parse a bundle from JSON
372    pub fn from_json(json: &str) -> Result<Self> {
373        serde_json::from_str(json)
374            .map_err(|e| LicenseError::InvalidLicenseFormat(format!("Invalid bundle JSON: {}", e)))
375    }
376
377    /// Encrypt the bundle using a hardware-derived key
378    pub fn encrypt(&self) -> Result<Vec<u8>> {
379        let json = self.to_json_compact()?;
380        encrypt_bundle(json.as_bytes())
381    }
382
383    /// Decrypt an encrypted bundle
384    pub fn decrypt(encrypted: &[u8]) -> Result<Self> {
385        let decrypted = decrypt_bundle(encrypted)?;
386        let json = String::from_utf8(decrypted)
387            .map_err(|e| LicenseError::InvalidLicenseFormat(format!("Invalid UTF-8: {}", e)))?;
388        Self::from_json(&json)
389    }
390
391    /// Save the bundle to a file (JSON format)
392    pub fn save(&self, path: &Path) -> Result<()> {
393        let json = self.to_json()?;
394        std::fs::write(path, json)?;
395        Ok(())
396    }
397
398    /// Save the bundle as encrypted binary
399    pub fn save_encrypted(&self, path: &Path) -> Result<()> {
400        let encrypted = self.encrypt()?;
401        std::fs::write(path, encrypted)?;
402        Ok(())
403    }
404
405    /// Load a bundle from a file (auto-detects format)
406    pub fn load(path: &Path) -> Result<Self> {
407        let data = std::fs::read(path)?;
408
409        // Check for encrypted format
410        if data.len() > 4 && &data[..4] == ENCRYPTED_BUNDLE_MAGIC {
411            Self::decrypt(&data)
412        } else {
413            let json = String::from_utf8(data)
414                .map_err(|e| LicenseError::InvalidLicenseFormat(format!("Invalid UTF-8: {}", e)))?;
415            Self::from_json(&json)
416        }
417    }
418}
419
420impl HardwareSummary {
421    /// Collect sanitized hardware information
422    pub fn collect() -> Self {
423        let hw = detect_hardware();
424        let fingerprint = HardwareFingerprint::from_hardware_info(&hw);
425
426        Self {
427            mac_addresses_partial: hw
428                .mac_addresses
429                .iter()
430                .map(|mac| sanitize_mac_address(mac))
431                .collect(),
432            mac_count: hw.mac_addresses.len(),
433            hostname_partial: hw.hostname.as_ref().map(|h| sanitize_hostname(h)),
434            disk_count: hw.disk_ids.len(),
435            disk_ids_hashed: hw.disk_ids.iter().map(|d| hash_identifier(d)).collect(),
436            has_machine_id: hw.machine_id.is_some(),
437            fingerprint_hash: fingerprint.combined_hash,
438        }
439    }
440
441    /// Create from existing HardwareInfo
442    pub fn from_hardware_info(hw: &HardwareInfo) -> Self {
443        let fingerprint = HardwareFingerprint::from_hardware_info(hw);
444
445        Self {
446            mac_addresses_partial: hw
447                .mac_addresses
448                .iter()
449                .map(|mac| sanitize_mac_address(mac))
450                .collect(),
451            mac_count: hw.mac_addresses.len(),
452            hostname_partial: hw.hostname.as_ref().map(|h| sanitize_hostname(h)),
453            disk_count: hw.disk_ids.len(),
454            disk_ids_hashed: hw.disk_ids.iter().map(|d| hash_identifier(d)).collect(),
455            has_machine_id: hw.machine_id.is_some(),
456            fingerprint_hash: fingerprint.combined_hash,
457        }
458    }
459}
460
461impl ClockState {
462    /// Collect clock state information
463    pub fn collect(state: Option<&LicenseState>) -> Self {
464        let now = Utc::now();
465
466        let (last_validation_time, drift_seconds, status) = match state {
467            Some(s) => {
468                let drift = (now - s.last_system_time).num_seconds();
469
470                let status = match s.detect_clock_manipulation(Duration::hours(1)) {
471                    Ok(ClockStatus::Ok { .. }) => ClockStatusSummary::Normal,
472                    Ok(ClockStatus::Backwards { drift, .. }) => {
473                        ClockStatusSummary::DriftedBackward {
474                            seconds: drift.num_seconds(),
475                        }
476                    }
477                    Ok(ClockStatus::SuspiciousJump { jump, .. }) => {
478                        ClockStatusSummary::JumpedForward {
479                            seconds: jump.num_seconds(),
480                        }
481                    }
482                    Err(e) => ClockStatusSummary::Unknown {
483                        reason: e.to_string(),
484                    },
485                };
486
487                (Some(s.last_system_time), Some(drift), status)
488            }
489            None => (None, None, ClockStatusSummary::NoPreviousState),
490        };
491
492        Self {
493            current_time: now,
494            last_validation_time,
495            drift_seconds,
496            status,
497            timezone: env::var("TZ").ok(),
498        }
499    }
500}
501
502impl EnvironmentInfo {
503    /// Collect environment information
504    pub fn collect() -> Self {
505        let runtime_env = RuntimeEnvironment::detect();
506
507        let runtime = match runtime_env {
508            RuntimeEnvironment::Standalone => RuntimeEnvironmentSummary::Native,
509            RuntimeEnvironment::Docker => RuntimeEnvironmentSummary::Docker,
510            RuntimeEnvironment::Kubernetes => RuntimeEnvironmentSummary::Kubernetes,
511            RuntimeEnvironment::AwsEc2 => RuntimeEnvironmentSummary::AwsEc2,
512            RuntimeEnvironment::GcpCompute => RuntimeEnvironmentSummary::GcpCompute,
513            RuntimeEnvironment::AzureVm => RuntimeEnvironmentSummary::AzureVm,
514            RuntimeEnvironment::GenericCloud => RuntimeEnvironmentSummary::GenericCloud,
515        };
516
517        let is_containerized = matches!(
518            runtime_env,
519            RuntimeEnvironment::Docker | RuntimeEnvironment::Kubernetes
520        );
521
522        let is_virtualized = matches!(
523            runtime_env,
524            RuntimeEnvironment::AwsEc2
525                | RuntimeEnvironment::GcpCompute
526                | RuntimeEnvironment::AzureVm
527                | RuntimeEnvironment::GenericCloud
528        );
529
530        let cloud_provider = match runtime_env {
531            RuntimeEnvironment::AwsEc2 => Some("AWS".to_string()),
532            RuntimeEnvironment::GcpCompute => Some("GCP".to_string()),
533            RuntimeEnvironment::AzureVm => Some("Azure".to_string()),
534            _ => None,
535        };
536
537        Self {
538            os_name: env::consts::OS.to_string(),
539            os_version: get_os_version(),
540            architecture: env::consts::ARCH.to_string(),
541            runtime,
542            is_containerized,
543            is_virtualized,
544            cloud_provider,
545            licenz_version: crate::VERSION.to_string(),
546        }
547    }
548}
549
550impl StateFileSummary {
551    /// Collect state file information for a license
552    pub fn collect(license_id: &str, state_integrity_key: &[u8; 32]) -> Self {
553        let state_manager = StateManager::new(license_id, *state_integrity_key);
554        let paths = state_manager.paths();
555
556        let mut valid_count = 0;
557        let mut invalid_count = 0;
558        let mut locations = Vec::new();
559
560        for path in paths {
561            let path_str = sanitize_path(&path.display().to_string());
562
563            if !path.exists() {
564                invalid_count += 1;
565                locations.push(StateFileLocation {
566                    path: path_str,
567                    status: StateFileLocationStatus::Missing,
568                });
569            } else {
570                match LicenseState::load(path, license_id, state_integrity_key) {
571                    Ok(Some(_)) => {
572                        valid_count += 1;
573                        locations.push(StateFileLocation {
574                            path: path_str,
575                            status: StateFileLocationStatus::Valid,
576                        });
577                    }
578                    Ok(None) => {
579                        invalid_count += 1;
580                        locations.push(StateFileLocation {
581                            path: path_str,
582                            status: StateFileLocationStatus::Missing,
583                        });
584                    }
585                    Err(LicenseError::StateFileTampered) => {
586                        invalid_count += 1;
587                        locations.push(StateFileLocation {
588                            path: path_str,
589                            status: StateFileLocationStatus::Tampered,
590                        });
591                    }
592                    Err(e) => {
593                        invalid_count += 1;
594                        locations.push(StateFileLocation {
595                            path: path_str,
596                            status: StateFileLocationStatus::Inaccessible {
597                                reason: e.to_string(),
598                            },
599                        });
600                    }
601                }
602            }
603        }
604
605        Self {
606            locations_checked: paths.len(),
607            valid_files: valid_count,
608            corrupted_or_missing: invalid_count,
609            locations,
610        }
611    }
612}
613
614impl VerificationEvent {
615    /// Create a new verification event
616    pub fn new(event_type: VerificationEventType, message: impl Into<String>) -> Self {
617        Self {
618            timestamp: Utc::now(),
619            event_type,
620            message: message.into(),
621            context: HashMap::new(),
622        }
623    }
624
625    /// Add context to the event
626    pub fn with_context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
627        self.context.insert(key.into(), value.into());
628        self
629    }
630}
631
632// =============================================================================
633// Sanitization Functions
634// =============================================================================
635
636/// Sanitize a MAC address (show first 3 octets, mask the rest)
637fn sanitize_mac_address(mac: &str) -> String {
638    let parts: Vec<&str> = mac.split(':').collect();
639    if parts.len() >= 3 {
640        format!(
641            "{}:{}:{}:XX:XX:XX",
642            parts[0].to_uppercase(),
643            parts[1].to_uppercase(),
644            parts[2].to_uppercase()
645        )
646    } else {
647        // Different separator or format
648        let hash = hash_identifier(mac);
649        format!("MAC-{}", &hash[..8])
650    }
651}
652
653/// Sanitize a hostname (show first chars + hash)
654fn sanitize_hostname(hostname: &str) -> String {
655    let visible_chars = hostname.chars().take(4).collect::<String>();
656    let hash = hash_identifier(hostname);
657    format!("{}...{}", visible_chars, &hash[..6])
658}
659
660/// Hash an identifier for safe storage
661fn hash_identifier(value: &str) -> String {
662    let mut hasher = Sha256::new();
663    hasher.update(value.as_bytes());
664    hex::encode(hasher.finalize())
665}
666
667/// Sanitize a file path (replace user-specific parts)
668fn sanitize_path(path: &str) -> String {
669    // Replace common user directory patterns
670    let path = if let Some(home) = dirs_next::home_dir() {
671        path.replace(&home.display().to_string(), "~")
672    } else {
673        path.to_string()
674    };
675
676    // Also handle Windows-style paths
677
678    path.replace("C:\\Users\\", "~\\")
679        .replace("/home/", "~/")
680        .replace("/Users/", "~/")
681}
682
683/// Get OS version information
684fn get_os_version() -> String {
685    // Try to get from environment or system
686    #[cfg(target_os = "linux")]
687    {
688        if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
689            for line in contents.lines() {
690                if line.starts_with("PRETTY_NAME=") {
691                    return line
692                        .trim_start_matches("PRETTY_NAME=")
693                        .trim_matches('"')
694                        .to_string();
695                }
696            }
697        }
698    }
699
700    #[cfg(target_os = "macos")]
701    {
702        if let Ok(output) = std::process::Command::new("sw_vers")
703            .arg("-productVersion")
704            .output()
705        {
706            return String::from_utf8_lossy(&output.stdout).trim().to_string();
707        }
708    }
709
710    #[cfg(target_os = "windows")]
711    {
712        if let Ok(output) = std::process::Command::new("cmd")
713            .args(["/C", "ver"])
714            .output()
715        {
716            return String::from_utf8_lossy(&output.stdout).trim().to_string();
717        }
718    }
719
720    "unknown".to_string()
721}
722
723// =============================================================================
724// Encryption Functions
725// =============================================================================
726
727/// Encrypt bundle data using hardware-derived key
728fn encrypt_bundle(data: &[u8]) -> Result<Vec<u8>> {
729    use aes_gcm::{
730        aead::{Aead, KeyInit},
731        Aes256Gcm, Nonce,
732    };
733
734    // Generate encryption key from hardware fingerprint
735    let fingerprint = HardwareFingerprint::generate();
736    let key = derive_bundle_key(&fingerprint.combined_hash);
737
738    // Generate random nonce
739    let nonce_bytes: [u8; 12] = rand::random();
740    let nonce = Nonce::from_slice(&nonce_bytes);
741
742    // Encrypt
743    let cipher = Aes256Gcm::new_from_slice(&key)
744        .map_err(|e| LicenseError::KeyGenerationFailed(e.to_string()))?;
745
746    let encrypted = cipher
747        .encrypt(nonce, data)
748        .map_err(|e| LicenseError::KeyGenerationFailed(format!("Encryption failed: {}", e)))?;
749
750    // Build output: MAGIC + VERSION + NONCE + ENCRYPTED_DATA
751    let mut output = Vec::with_capacity(4 + 1 + 12 + encrypted.len());
752    output.extend_from_slice(ENCRYPTED_BUNDLE_MAGIC);
753    output.push(BUNDLE_VERSION);
754    output.extend_from_slice(&nonce_bytes);
755    output.extend_from_slice(&encrypted);
756
757    Ok(output)
758}
759
760/// Decrypt bundle data using hardware-derived key
761fn decrypt_bundle(data: &[u8]) -> Result<Vec<u8>> {
762    use aes_gcm::{
763        aead::{Aead, KeyInit},
764        Aes256Gcm, Nonce,
765    };
766
767    // Check minimum length and magic
768    if data.len() < 17 {
769        return Err(LicenseError::InvalidLicenseFormat(
770            "Encrypted bundle too short".into(),
771        ));
772    }
773
774    if &data[..4] != ENCRYPTED_BUNDLE_MAGIC {
775        return Err(LicenseError::InvalidLicenseFormat(
776            "Invalid encrypted bundle format".into(),
777        ));
778    }
779
780    let version = data[4];
781    if version != BUNDLE_VERSION {
782        return Err(LicenseError::InvalidLicenseFormat(format!(
783            "Unsupported bundle version: {}",
784            version
785        )));
786    }
787
788    let nonce_bytes: [u8; 12] = data[5..17]
789        .try_into()
790        .map_err(|_| LicenseError::InvalidLicenseFormat("Invalid nonce".into()))?;
791
792    let encrypted = &data[17..];
793
794    // Derive key from hardware fingerprint
795    let fingerprint = HardwareFingerprint::generate();
796    let key = derive_bundle_key(&fingerprint.combined_hash);
797
798    // Decrypt
799    let cipher = Aes256Gcm::new_from_slice(&key)
800        .map_err(|e| LicenseError::KeyGenerationFailed(e.to_string()))?;
801
802    let nonce = Nonce::from_slice(&nonce_bytes);
803
804    cipher.decrypt(nonce, encrypted).map_err(|_| {
805        LicenseError::InvalidLicenseFormat(
806            "Decryption failed - bundle may be from a different machine".into(),
807        )
808    })
809}
810
811/// Derive encryption key from fingerprint hash
812fn derive_bundle_key(fingerprint_hash: &str) -> [u8; 32] {
813    let mut hasher = Sha256::new();
814    hasher.update(b"licenz-support-bundle-v1:");
815    hasher.update(fingerprint_hash.as_bytes());
816
817    let result = hasher.finalize();
818    let mut key = [0u8; 32];
819    key.copy_from_slice(&result);
820    key
821}
822
823// =============================================================================
824// Builder Pattern
825// =============================================================================
826
827/// Builder for creating support bundles
828pub struct SupportBundleBuilder {
829    bundle: SupportBundle,
830}
831
832impl SupportBundleBuilder {
833    /// Create a new builder
834    pub fn new() -> Self {
835        Self {
836            bundle: SupportBundle::generate(),
837        }
838    }
839
840    /// Create a builder with license context
841    pub fn with_license_id(license_id: &str, state_integrity_key: &[u8; 32]) -> Self {
842        Self {
843            bundle: SupportBundle::generate_with_license(license_id, state_integrity_key),
844        }
845    }
846
847    /// Set license status
848    pub fn license_status(mut self, status: LicenseStatusSummary) -> Self {
849        self.bundle.license_status = Some(status);
850        self
851    }
852
853    /// Add a verification event
854    pub fn event(mut self, event: VerificationEvent) -> Self {
855        self.bundle.verification_log.push(event);
856        self
857    }
858
859    /// Add metadata
860    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
861        self.bundle.metadata.insert(key.into(), value.into());
862        self
863    }
864
865    /// Build the final bundle
866    pub fn build(self) -> SupportBundle {
867        self.bundle
868    }
869}
870
871impl Default for SupportBundleBuilder {
872    fn default() -> Self {
873        Self::new()
874    }
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880
881    #[test]
882    fn test_generate_basic_bundle() {
883        let bundle = SupportBundle::generate();
884
885        assert_eq!(bundle.version, BUNDLE_VERSION);
886        // mac_count is usize, so always >= 0 - just verify it exists
887        let _ = bundle.hardware.mac_count;
888        assert!(!bundle.environment.licenz_version.is_empty());
889    }
890
891    #[test]
892    fn test_sanitize_mac_address() {
893        let mac = "AA:BB:CC:DD:EE:FF";
894        let sanitized = sanitize_mac_address(mac);
895        assert_eq!(sanitized, "AA:BB:CC:XX:XX:XX");
896        assert!(!sanitized.contains("DD"));
897        assert!(!sanitized.contains("EE"));
898        assert!(!sanitized.contains("FF"));
899    }
900
901    #[test]
902    fn test_sanitize_hostname() {
903        let hostname = "my-production-server";
904        let sanitized = sanitize_hostname(hostname);
905        assert!(sanitized.starts_with("my-p"));
906        assert!(sanitized.contains("..."));
907    }
908
909    #[test]
910    fn test_bundle_json_round_trip() {
911        let bundle = SupportBundle::generate();
912        let json = bundle.to_json().unwrap();
913        let parsed = SupportBundle::from_json(&json).unwrap();
914
915        assert_eq!(bundle.version, parsed.version);
916        assert_eq!(
917            bundle.hardware.fingerprint_hash,
918            parsed.hardware.fingerprint_hash
919        );
920    }
921
922    #[test]
923    fn test_bundle_encryption_round_trip() {
924        let bundle = SupportBundle::generate();
925        let encrypted = bundle.encrypt().unwrap();
926
927        assert!(encrypted.starts_with(ENCRYPTED_BUNDLE_MAGIC));
928
929        let decrypted = SupportBundle::decrypt(&encrypted).unwrap();
930        assert_eq!(bundle.version, decrypted.version);
931    }
932
933    #[test]
934    fn test_verification_event() {
935        let event = VerificationEvent::new(VerificationEventType::Success, "License validated")
936            .with_context("license_id", "LIC-001");
937
938        assert!(matches!(event.event_type, VerificationEventType::Success));
939        assert_eq!(
940            event.context.get("license_id"),
941            Some(&"LIC-001".to_string())
942        );
943    }
944
945    #[test]
946    fn test_builder_pattern() {
947        let bundle = SupportBundleBuilder::new()
948            .metadata("support_ticket", "TICKET-12345")
949            .event(VerificationEvent::new(
950                VerificationEventType::Error,
951                "Test error",
952            ))
953            .build();
954
955        assert_eq!(
956            bundle.metadata.get("support_ticket"),
957            Some(&"TICKET-12345".to_string())
958        );
959        assert_eq!(bundle.verification_log.len(), 1);
960    }
961}