Skip to main content

licenz_core/
unlock.rs

1//! Admin Time Unlock - Client-side unlock verification
2//!
3//! This module provides the client-side functionality for the challenge-response
4//! unlock system used to reset clock tampering lockouts on airgapped machines.
5//!
6//! # Security Model
7//!
8//! The unlock system uses a challenge-response protocol:
9//! 1. Client generates a challenge from its current locked state
10//! 2. Admin verifies the challenge and signs a response
11//! 3. Client validates the response signature and unlocks
12//!
13//! The response code is signed with the organization's private key and contains:
14//! - Timestamp (to prevent replay attacks)
15//! - Unlock type (to limit scope of unlock)
16//! - Signature over machine fingerprint and nonce
17//!
18//! # Usage
19//!
20//! ```rust,ignore
21//! use licenz_core::unlock::{generate_challenge_from_state, validate_response_code, UnlockType};
22//!
23//! // On locked machine, generate challenge
24//! let state_key = [0u8; 32]; // same key used for LicenseState HMAC
25//! let challenge = generate_challenge_from_state(None, None, UnlockType::ClockReset, &state_key)?;
26//! println!("Challenge: {}", challenge.challenge_code);
27//!
28//! // Admin signs the challenge and sends back raw response bytes
29//! let response_bytes: &[u8] = &[/* timestamp(8) || unlock_type(1) || signature */];
30//! let result = validate_response_code(
31//!     response_bytes, None, "LIC-ID", &state_key, &public_key_pem, "Ed25519",
32//! )?;
33//! if result.success {
34//!     println!("Unlocked!");
35//! }
36//! ```
37
38use crate::anti_tamper::{ClockStatus, HardwareFingerprint, LicenseState};
39use crate::crypto::CryptoRegistry;
40use crate::error::{LicenseError, Result};
41use crate::state_manager::StateManager;
42use chrono::{DateTime, Duration, Utc};
43use hmac::{Hmac, Mac};
44use serde::{Deserialize, Serialize};
45use sha2::{Digest, Sha256};
46use std::path::{Path, PathBuf};
47
48// ============================================================================
49// Types
50// ============================================================================
51
52/// Types of unlock operations supported
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
54pub enum UnlockType {
55    /// Reset clock tampering detection state only
56    #[default]
57    ClockReset,
58    /// Reset activation state (re-activate license)
59    ActivationReset,
60    /// Full reset (clock + activation + all state files)
61    FullReset,
62}
63
64/// Generated unlock challenge
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct UnlockChallenge {
67    /// The challenge code to communicate (formatted: XXX-XXX-XXX-XXX)
68    pub challenge_code: String,
69
70    /// Hash of the machine fingerprint (for verification)
71    pub fingerprint_hash: String,
72
73    /// Full fingerprint data (for server-side storage)
74    pub fingerprint: HardwareFingerprint,
75
76    /// Timestamp when challenge was generated
77    pub timestamp: DateTime<Utc>,
78
79    /// Random nonce for uniqueness
80    pub nonce: String,
81
82    /// Type of unlock requested
83    pub unlock_type: UnlockType,
84}
85
86/// Result of unlock validation
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct UnlockResult {
89    /// Whether the unlock was successful
90    pub success: bool,
91
92    /// Type of unlock performed
93    pub unlock_type: UnlockType,
94
95    /// Human-readable message
96    pub message: String,
97
98    /// List of state files that were reset
99    pub files_reset: Vec<String>,
100}
101
102/// Current lockout status information
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct LockoutStatus {
105    /// Whether the machine is currently locked out
106    pub is_locked: bool,
107
108    /// Reason for lockout (if locked)
109    pub lock_reason: Option<String>,
110
111    /// When the lockout occurred
112    pub locked_at: Option<DateTime<Utc>>,
113
114    /// Current clock status
115    pub clock_status: ClockStatus,
116
117    /// Last successful validation time
118    pub last_validated: Option<DateTime<Utc>>,
119
120    /// Total validation count
121    pub validation_count: u64,
122}
123
124/// A pending challenge stored on disk for later verification.
125///
126/// This binds the nonce and metadata generated during challenge creation
127/// so that the response can be verified against the original challenge.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129struct PendingChallenge {
130    /// Random nonce generated during challenge
131    nonce: String,
132    /// When the challenge was generated
133    timestamp: DateTime<Utc>,
134    /// Type of unlock requested
135    unlock_type: UnlockType,
136    /// SHA-256 hash of the hardware fingerprint
137    fingerprint_hash: String,
138}
139
140type HmacSha256 = Hmac<Sha256>;
141
142impl PendingChallenge {
143    /// Save pending challenge to disk with HMAC integrity protection
144    fn save(&self, state_dir: &Path, state_integrity_key: &[u8; 32]) -> Result<()> {
145        let json = serde_json::to_vec(self)
146            .map_err(|e| LicenseError::SerializationError(e.to_string()))?;
147
148        let mut mac = HmacSha256::new_from_slice(state_integrity_key)
149            .map_err(|e| LicenseError::SigningFailed(format!("HMAC init: {}", e)))?;
150        mac.update(&json);
151        let tag = mac.finalize().into_bytes();
152
153        // Format: [tag (32 bytes)] || [json]
154        let mut data = Vec::with_capacity(32 + json.len());
155        data.extend_from_slice(&tag);
156        data.extend_from_slice(&json);
157
158        let path = Self::challenge_path(state_dir);
159        std::fs::create_dir_all(state_dir)?;
160        std::fs::write(&path, &data)?;
161
162        Ok(())
163    }
164
165    /// Load and verify a pending challenge from disk (one-time read)
166    fn load_and_delete(state_dir: &Path, state_integrity_key: &[u8; 32]) -> Result<Self> {
167        let path = Self::challenge_path(state_dir);
168
169        let data = std::fs::read(&path).map_err(|_| {
170            LicenseError::InvalidResponseCode(
171                "No pending challenge found. Generate a challenge first.".to_string(),
172            )
173        })?;
174
175        // Delete immediately to prevent replay
176        let _ = std::fs::remove_file(&path);
177
178        if data.len() < 33 {
179            return Err(LicenseError::InvalidResponseCode(
180                "Corrupted challenge file".to_string(),
181            ));
182        }
183
184        let (tag_bytes, json) = data.split_at(32);
185
186        // Verify HMAC
187        let mut mac = HmacSha256::new_from_slice(state_integrity_key)
188            .map_err(|e| LicenseError::VerificationFailed(format!("HMAC init: {}", e)))?;
189        mac.update(json);
190        mac.verify_slice(tag_bytes).map_err(|_| {
191            LicenseError::VerificationFailed("Challenge file integrity check failed".to_string())
192        })?;
193
194        serde_json::from_slice(json)
195            .map_err(|e| LicenseError::InvalidResponseCode(format!("Corrupt challenge: {}", e)))
196    }
197
198    fn challenge_path(state_dir: &Path) -> PathBuf {
199        state_dir.join("pending.challenge")
200    }
201}
202
203// ============================================================================
204// Challenge Generation
205// ============================================================================
206
207/// Generate a challenge code from the current machine state
208///
209/// This reads the machine's hardware fingerprint and current state to generate
210/// a unique challenge that can be verified by the server.
211///
212/// # Arguments
213///
214/// * `state_dir` - Optional custom state directory (uses default if None)
215/// * `license_id` - Optional license ID to associate with the challenge
216/// * `unlock_type` - Type of unlock being requested
217///
218/// # Returns
219///
220/// An `UnlockChallenge` containing the challenge code and metadata
221pub fn generate_challenge_from_state(
222    state_dir: Option<&Path>,
223    _license_id: Option<&str>,
224    unlock_type: UnlockType,
225    state_integrity_key: &[u8; 32],
226) -> Result<UnlockChallenge> {
227    // Generate hardware fingerprint
228    let fingerprint = HardwareFingerprint::generate();
229
230    // Generate random nonce
231    let nonce = generate_nonce();
232
233    // Get timestamp
234    let timestamp = Utc::now();
235
236    // Generate the challenge code
237    let challenge_code = generate_challenge_code(&fingerprint, timestamp, &nonce);
238
239    // Persist the pending challenge for later verification
240    let pending = PendingChallenge {
241        nonce: nonce.clone(),
242        timestamp,
243        unlock_type,
244        fingerprint_hash: fingerprint.combined_hash.clone(),
245    };
246
247    let dir = state_dir.unwrap_or_else(|| Path::new("."));
248    pending.save(dir, state_integrity_key)?;
249
250    Ok(UnlockChallenge {
251        challenge_code,
252        fingerprint_hash: fingerprint.combined_hash.clone(),
253        fingerprint,
254        timestamp,
255        nonce,
256        unlock_type,
257    })
258}
259
260/// Generate a short, readable challenge code
261///
262/// Format: XXX-XXX-XXX-XXX (12 chars total, 15 with dashes)
263fn generate_challenge_code(
264    fingerprint: &HardwareFingerprint,
265    timestamp: DateTime<Utc>,
266    nonce: &str,
267) -> String {
268    // Use uppercase alphanumeric chars that are easy to read/speak
269    // Avoid confusing chars: 0/O, 1/I/L
270    const ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";
271
272    // Hash the inputs
273    let mut hasher = Sha256::new();
274    hasher.update(fingerprint.combined_hash.as_bytes());
275    hasher.update(timestamp.timestamp().to_le_bytes());
276    hasher.update(nonce.as_bytes());
277    let hash = hasher.finalize();
278
279    // Convert to 12-char code
280    let mut code = String::with_capacity(12);
281    for i in 0..12 {
282        let idx = (hash[i] as usize) % ALPHABET.len();
283        code.push(ALPHABET[idx] as char);
284    }
285
286    // Format as XXX-XXX-XXX-XXX for readability
287    format!(
288        "{}-{}-{}-{}",
289        &code[0..3],
290        &code[3..6],
291        &code[6..9],
292        &code[9..12]
293    )
294}
295
296/// Generate a random nonce
297fn generate_nonce() -> String {
298    use rand::Rng;
299    let bytes: [u8; 32] = rand::thread_rng().gen();
300    hex::encode(bytes)
301}
302
303// ============================================================================
304// Response Validation
305// ============================================================================
306
307/// Validate a response code and apply the unlock if valid
308///
309/// The response is raw bytes: `[timestamp(8)] || [unlock_type(1)] || [signature(variable)]`.
310/// The admin signs `SHA256(nonce || timestamp_bytes || unlock_type_byte || fingerprint_hash)`
311/// using the organization's private key. The pending challenge (saved during
312/// `generate_challenge_from_state`) supplies the nonce and fingerprint_hash for
313/// reconstruction.
314///
315/// # Arguments
316///
317/// * `response_bytes` - The raw response bytes from the admin
318/// * `state_dir` - Optional custom state directory (must match the one used for challenge)
319/// * `license_id` - The license ID
320/// * `state_integrity_key` - The state HMAC key (same key used for LicenseState)
321/// * `public_key_pem` - The organization's public key for signature verification
322/// * `algorithm_id` - The signature algorithm used (e.g., "RSA-SHA256", "Ed25519")
323///
324/// # Returns
325///
326/// An `UnlockResult` indicating success/failure and what was reset
327pub fn validate_response_code(
328    response_bytes: &[u8],
329    state_dir: Option<&Path>,
330    license_id: &str,
331    state_integrity_key: &[u8; 32],
332    public_key_pem: &str,
333    algorithm_id: &str,
334) -> Result<UnlockResult> {
335    // Minimum: 8 (timestamp) + 1 (unlock_type) + 1 (signature)
336    if response_bytes.len() < 10 {
337        return Err(LicenseError::InvalidResponseCode(
338            "Response too short".to_string(),
339        ));
340    }
341
342    // Parse timestamp (8 bytes LE)
343    let timestamp_bytes: [u8; 8] = response_bytes[0..8]
344        .try_into()
345        .map_err(|_| LicenseError::InvalidResponseCode("Invalid timestamp".to_string()))?;
346    let timestamp = i64::from_le_bytes(timestamp_bytes);
347    let response_time = DateTime::from_timestamp(timestamp, 0)
348        .ok_or_else(|| LicenseError::InvalidResponseCode("Invalid timestamp value".to_string()))?;
349
350    // Check if response has expired (24 hours max)
351    let now = Utc::now();
352    let age = now - response_time;
353    if age > Duration::hours(24) {
354        return Ok(UnlockResult {
355            success: false,
356            unlock_type: UnlockType::ClockReset,
357            message: "Response code has expired (older than 24 hours)".to_string(),
358            files_reset: vec![],
359        });
360    }
361
362    // Parse unlock type (1 byte)
363    let unlock_type = match response_bytes[8] {
364        1 => UnlockType::ClockReset,
365        2 => UnlockType::ActivationReset,
366        3 => UnlockType::FullReset,
367        _ => {
368            return Err(LicenseError::InvalidResponseCode(
369                "Invalid unlock type".to_string(),
370            ))
371        }
372    };
373
374    // Remaining bytes are the signature
375    let signature = &response_bytes[9..];
376
377    // Load the pending challenge (deletes it to prevent replay)
378    let dir = state_dir.unwrap_or_else(|| Path::new("."));
379    let pending = PendingChallenge::load_and_delete(dir, state_integrity_key)?;
380
381    // Check challenge hasn't expired (challenge itself has a 24-hour window)
382    let challenge_age = now - pending.timestamp;
383    if challenge_age > Duration::hours(24) {
384        return Ok(UnlockResult {
385            success: false,
386            unlock_type,
387            message: "Challenge has expired (older than 24 hours). Generate a new one.".to_string(),
388            files_reset: vec![],
389        });
390    }
391
392    // Reconstruct the signed message: SHA256(nonce || timestamp || unlock_type || fingerprint_hash)
393    let unlock_type_byte = response_bytes[8];
394    let mut hasher = Sha256::new();
395    hasher.update(pending.nonce.as_bytes());
396    hasher.update(timestamp_bytes);
397    hasher.update([unlock_type_byte]);
398    hasher.update(pending.fingerprint_hash.as_bytes());
399    let message = hasher.finalize();
400
401    // Verify the signature using the specified algorithm
402    let algorithm = CryptoRegistry::get_signature_algorithm(algorithm_id)?;
403    algorithm
404        .verify(&message, signature, public_key_pem)
405        .map_err(|_| {
406            LicenseError::InvalidResponseCode("Invalid signature on response code".to_string())
407        })?;
408
409    // Apply the unlock
410    let files_reset = apply_unlock(state_dir, unlock_type, license_id, state_integrity_key)?;
411
412    Ok(UnlockResult {
413        success: true,
414        unlock_type,
415        message: "Machine successfully unlocked".to_string(),
416        files_reset,
417    })
418}
419
420/// Apply the unlock by resetting appropriate state files
421fn apply_unlock(
422    state_dir: Option<&Path>,
423    unlock_type: UnlockType,
424    license_id: &str,
425    state_integrity_key: &[u8; 32],
426) -> Result<Vec<String>> {
427    let mut files_reset = Vec::new();
428
429    let paths = unlock_state_paths(state_dir, license_id, state_integrity_key);
430
431    match unlock_type {
432        UnlockType::ClockReset => {
433            for path in &paths {
434                if path.exists() {
435                    if let Ok(Some(mut state)) =
436                        LicenseState::load(path, license_id, state_integrity_key)
437                    {
438                        state.last_system_time = Utc::now();
439                        if state.save(path, state_integrity_key).is_ok() {
440                            files_reset.push(path.display().to_string());
441                        }
442                    }
443                }
444            }
445        }
446        UnlockType::ActivationReset => {
447            for path in &paths {
448                if path.exists() {
449                    if let Ok(Some(mut state)) =
450                        LicenseState::load(path, license_id, state_integrity_key)
451                    {
452                        state.last_validated = Utc::now();
453                        state.last_system_time = Utc::now();
454                        if state.save(path, state_integrity_key).is_ok() {
455                            files_reset.push(path.display().to_string());
456                        }
457                    }
458                }
459            }
460        }
461        UnlockType::FullReset => {
462            for path in &paths {
463                if path.exists() && std::fs::remove_file(path).is_ok() {
464                    files_reset.push(path.display().to_string());
465                }
466            }
467        }
468    }
469
470    Ok(files_reset)
471}
472
473fn unlock_state_paths(
474    state_dir: Option<&Path>,
475    license_id: &str,
476    state_integrity_key: &[u8; 32],
477) -> Vec<PathBuf> {
478    if let Some(dir) = state_dir {
479        vec![dir.join("license.state")]
480    } else {
481        StateManager::new(license_id, *state_integrity_key)
482            .paths()
483            .to_vec()
484    }
485}
486
487// ============================================================================
488// Lockout Status
489// ============================================================================
490
491/// Get the current lockout status of the machine
492///
493/// This checks all state files and clock status to determine if the machine
494/// is locked out and why.
495pub fn get_lockout_status(
496    state_dir: Option<&Path>,
497    license_id: &str,
498    state_integrity_key: &[u8; 32],
499) -> Result<LockoutStatus> {
500    let paths = unlock_state_paths(state_dir, license_id, state_integrity_key);
501
502    let mut is_locked = false;
503    let mut lock_reason = None;
504    let mut locked_at = None;
505    let mut clock_status = ClockStatus::Ok {
506        current: Utc::now(),
507    };
508    let mut last_validated = None;
509    let mut validation_count = 0u64;
510
511    // Check each state file
512    for path in &paths {
513        if path.exists() {
514            if let Ok(Some(state)) = LicenseState::load(path, license_id, state_integrity_key) {
515                if last_validated.is_none() || state.last_validated > last_validated.unwrap() {
516                    last_validated = Some(state.last_validated);
517                }
518                validation_count = validation_count.max(state.validation_count);
519
520                if let Ok(status) = state.detect_clock_manipulation(Duration::hours(1)) {
521                    match &status {
522                        ClockStatus::Backwards { last_seen, .. } => {
523                            is_locked = true;
524                            lock_reason =
525                                Some("Clock tampering detected: time moved backwards".to_string());
526                            locked_at = Some(*last_seen);
527                            clock_status = status.clone();
528                        }
529                        ClockStatus::SuspiciousJump { last_seen, .. } => {
530                            is_locked = true;
531                            lock_reason =
532                                Some("Clock tampering suspected: suspicious time jump".to_string());
533                            locked_at = Some(*last_seen);
534                            clock_status = status.clone();
535                        }
536                        ClockStatus::Ok { .. } => {
537                            if matches!(clock_status, ClockStatus::Ok { .. }) {
538                                clock_status = status.clone();
539                            }
540                        }
541                    }
542                }
543            }
544        }
545    }
546
547    Ok(LockoutStatus {
548        is_locked,
549        lock_reason,
550        locked_at,
551        clock_status,
552        last_validated,
553        validation_count,
554    })
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn test_challenge_code_format() {
563        let fingerprint = HardwareFingerprint::default();
564        let timestamp = Utc::now();
565        let nonce = generate_nonce();
566
567        let code = generate_challenge_code(&fingerprint, timestamp, &nonce);
568
569        // Should be in format XXX-XXX-XXX-XXX
570        assert_eq!(code.len(), 15); // 12 chars + 3 dashes
571        assert!(code.chars().filter(|c| *c == '-').count() == 3);
572
573        // All non-dash chars should be alphanumeric
574        for c in code.chars() {
575            if c != '-' {
576                assert!(c.is_ascii_alphanumeric());
577            }
578        }
579    }
580
581    #[test]
582    fn test_nonce_uniqueness() {
583        let nonce1 = generate_nonce();
584        let nonce2 = generate_nonce();
585
586        assert_ne!(nonce1, nonce2);
587        assert_eq!(nonce1.len(), 64); // 32 bytes -> 64 hex chars
588    }
589
590    #[test]
591    fn test_unlock_type_default() {
592        let unlock_type = UnlockType::default();
593        assert_eq!(unlock_type, UnlockType::ClockReset);
594    }
595
596    #[test]
597    fn test_pending_challenge_round_trip() {
598        let dir = tempfile::tempdir().unwrap();
599        let key = [0xABu8; 32];
600
601        let pending = PendingChallenge {
602            nonce: "test-nonce-123".to_string(),
603            timestamp: Utc::now(),
604            unlock_type: UnlockType::ClockReset,
605            fingerprint_hash: "abc123".to_string(),
606        };
607
608        pending.save(dir.path(), &key).unwrap();
609
610        let loaded = PendingChallenge::load_and_delete(dir.path(), &key).unwrap();
611        assert_eq!(loaded.nonce, pending.nonce);
612        assert_eq!(loaded.fingerprint_hash, pending.fingerprint_hash);
613        assert_eq!(loaded.unlock_type, pending.unlock_type);
614
615        // Second load should fail (file deleted)
616        assert!(PendingChallenge::load_and_delete(dir.path(), &key).is_err());
617    }
618
619    #[test]
620    fn test_pending_challenge_wrong_key_fails() {
621        let dir = tempfile::tempdir().unwrap();
622        let key1 = [0xABu8; 32];
623        let key2 = [0xCDu8; 32];
624
625        let pending = PendingChallenge {
626            nonce: "test-nonce".to_string(),
627            timestamp: Utc::now(),
628            unlock_type: UnlockType::FullReset,
629            fingerprint_hash: "xyz".to_string(),
630        };
631
632        pending.save(dir.path(), &key1).unwrap();
633
634        // Loading with wrong key should fail HMAC verification
635        assert!(PendingChallenge::load_and_delete(dir.path(), &key2).is_err());
636    }
637
638    #[test]
639    fn test_validate_response_with_real_signature() {
640        use crate::crypto::algorithm_ids;
641        use crate::keys::CryptoKeyPair;
642
643        let dir = tempfile::tempdir().unwrap();
644        let state_key = [0x42u8; 32];
645
646        // Generate a challenge (saves pending challenge to disk)
647        let challenge = generate_challenge_from_state(
648            Some(dir.path()),
649            Some("TEST-LIC"),
650            UnlockType::ClockReset,
651            &state_key,
652        )
653        .unwrap();
654
655        // Admin side: sign the response
656        let keypair = CryptoKeyPair::generate(algorithm_ids::ED25519).unwrap();
657
658        let timestamp = Utc::now();
659        let timestamp_bytes = timestamp.timestamp().to_le_bytes();
660        let unlock_type_byte: u8 = 1; // ClockReset
661
662        // Reconstruct the message the same way validate_response_code does
663        let mut hasher = Sha256::new();
664        hasher.update(challenge.nonce.as_bytes());
665        hasher.update(timestamp_bytes);
666        hasher.update([unlock_type_byte]);
667        hasher.update(challenge.fingerprint_hash.as_bytes());
668        let message = hasher.finalize();
669
670        let algorithm = CryptoRegistry::get_signature_algorithm(algorithm_ids::ED25519).unwrap();
671        let signature = algorithm.sign(&message, keypair.private_key_pem()).unwrap();
672
673        // Build response bytes: [timestamp(8)] || [unlock_type(1)] || [signature]
674        let mut response = Vec::new();
675        response.extend_from_slice(&timestamp_bytes);
676        response.push(unlock_type_byte);
677        response.extend_from_slice(&signature);
678
679        // Validate
680        let result = validate_response_code(
681            &response,
682            Some(dir.path()),
683            "TEST-LIC",
684            &state_key,
685            &keypair.public_key_pem,
686            algorithm_ids::ED25519,
687        )
688        .unwrap();
689
690        assert!(result.success, "Expected success, got: {}", result.message);
691        assert_eq!(result.unlock_type, UnlockType::ClockReset);
692    }
693
694    #[test]
695    fn test_validate_response_wrong_signature_fails() {
696        use crate::crypto::algorithm_ids;
697        use crate::keys::CryptoKeyPair;
698
699        let dir = tempfile::tempdir().unwrap();
700        let state_key = [0x42u8; 32];
701
702        let _challenge = generate_challenge_from_state(
703            Some(dir.path()),
704            Some("TEST-LIC"),
705            UnlockType::ClockReset,
706            &state_key,
707        )
708        .unwrap();
709
710        // Sign with one key, verify with another
711        let _signing_keypair = CryptoKeyPair::generate(algorithm_ids::ED25519).unwrap();
712        let wrong_keypair = CryptoKeyPair::generate(algorithm_ids::ED25519).unwrap();
713
714        let timestamp_bytes = Utc::now().timestamp().to_le_bytes();
715
716        // Build a response with garbage signature
717        let mut response = Vec::new();
718        response.extend_from_slice(&timestamp_bytes);
719        response.push(1); // ClockReset
720        response.extend_from_slice(&[0u8; 64]); // Fake Ed25519 signature
721
722        let result = validate_response_code(
723            &response,
724            Some(dir.path()),
725            "TEST-LIC",
726            &state_key,
727            &wrong_keypair.public_key_pem,
728            algorithm_ids::ED25519,
729        );
730
731        assert!(result.is_err());
732    }
733
734    #[test]
735    fn test_validate_response_no_pending_challenge_fails() {
736        use crate::crypto::algorithm_ids;
737        use crate::keys::CryptoKeyPair;
738
739        let dir = tempfile::tempdir().unwrap();
740        let state_key = [0x42u8; 32];
741        let keypair = CryptoKeyPair::generate(algorithm_ids::ED25519).unwrap();
742
743        // No challenge was generated; try to validate directly
744        let mut response = Vec::new();
745        response.extend_from_slice(&Utc::now().timestamp().to_le_bytes());
746        response.push(1);
747        response.extend_from_slice(&[0u8; 64]);
748
749        let result = validate_response_code(
750            &response,
751            Some(dir.path()),
752            "TEST-LIC",
753            &state_key,
754            &keypair.public_key_pem,
755            algorithm_ids::ED25519,
756        );
757
758        assert!(result.is_err());
759    }
760
761    #[test]
762    fn test_validate_response_replay_fails() {
763        use crate::crypto::algorithm_ids;
764        use crate::keys::CryptoKeyPair;
765
766        let dir = tempfile::tempdir().unwrap();
767        let state_key = [0x42u8; 32];
768
769        let challenge = generate_challenge_from_state(
770            Some(dir.path()),
771            Some("TEST-LIC"),
772            UnlockType::ClockReset,
773            &state_key,
774        )
775        .unwrap();
776
777        let keypair = CryptoKeyPair::generate(algorithm_ids::ED25519).unwrap();
778
779        let timestamp = Utc::now();
780        let timestamp_bytes = timestamp.timestamp().to_le_bytes();
781
782        let mut hasher = Sha256::new();
783        hasher.update(challenge.nonce.as_bytes());
784        hasher.update(timestamp_bytes);
785        hasher.update([1u8]);
786        hasher.update(challenge.fingerprint_hash.as_bytes());
787        let message = hasher.finalize();
788
789        let algorithm = CryptoRegistry::get_signature_algorithm(algorithm_ids::ED25519).unwrap();
790        let signature = algorithm.sign(&message, keypair.private_key_pem()).unwrap();
791
792        let mut response = Vec::new();
793        response.extend_from_slice(&timestamp_bytes);
794        response.push(1);
795        response.extend_from_slice(&signature);
796
797        // First validation succeeds
798        let result = validate_response_code(
799            &response,
800            Some(dir.path()),
801            "TEST-LIC",
802            &state_key,
803            &keypair.public_key_pem,
804            algorithm_ids::ED25519,
805        )
806        .unwrap();
807        assert!(result.success);
808
809        // Replay attempt fails (challenge was deleted)
810        let result2 = validate_response_code(
811            &response,
812            Some(dir.path()),
813            "TEST-LIC",
814            &state_key,
815            &keypair.public_key_pem,
816            algorithm_ids::ED25519,
817        );
818        assert!(result2.is_err());
819    }
820}