ironshield_types/
challenge.rs

1use crate::serde_utils::{
2    deserialize_32_bytes,
3    deserialize_signature,
4    serialize_32_bytes,
5    serialize_signature
6};
7
8use chrono::Utc;
9use ed25519_dalek::SigningKey;
10use hex;
11use rand;
12use serde::{
13    Deserialize,
14    Serialize
15};
16
17pub const CHALLENGE_DIFFICULTY:   u64 = 200_000_000u64;
18
19const                HASH_BITS: usize = 256;
20const               ARRAY_SIZE: usize = 32;
21const            BITS_PER_BYTE: usize = 8;
22const       BITS_PER_BYTE_MASK: usize = 7;
23const           MAX_BYTE_VALUE:    u8 = 0xFF;
24const         MAX_BIT_POSITION: usize = 255;
25const                LSB_INDEX: usize = ARRAY_SIZE - 1;
26const                LSB_VALUE:    u8 = 1;
27
28/// IronShield Challenge structure for the proof-of-work algorithm
29///
30/// * `random_nonce`:         The SHA-256 hash of a random number (hex string).
31/// * `created_time`:         Unix milli timestamp for the challenge.
32/// * `expiration_time`:      Unix milli timestamp for the challenge expiration time.
33/// * `challenge_param`:      Target threshold - hash must be less than this value.
34/// * `recommended_attempts`: Expected number of attempts for user guidance (3x difficulty).
35/// * `website_id`:           The identifier of the website.
36/// * `public_key`:           Ed25519 public key for signature verification.
37/// * `challenge_signature`:  Ed25519 signature over the challenge data.
38#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct IronShieldChallenge {
41    pub random_nonce:        String,
42    pub created_time:        i64,
43    pub expiration_time:     i64,
44    pub website_id:          String,
45    #[serde(
46        serialize_with = "serialize_32_bytes",
47        deserialize_with = "deserialize_32_bytes"
48    )]
49    pub challenge_param:      [u8; 32],
50    pub recommended_attempts: u64,
51    #[serde(
52        serialize_with = "serialize_32_bytes",
53        deserialize_with = "deserialize_32_bytes"
54    )]
55    pub public_key:          [u8; 32],
56    #[serde(
57        serialize_with = "serialize_signature",
58        deserialize_with = "deserialize_signature"
59    )]
60    pub challenge_signature: [u8; 64],
61}
62
63impl IronShieldChallenge {
64    /// Constructor for creating a new `IronShieldChallenge` instance.
65    ///
66    /// This function creates a new challenge and automatically generates a cryptographic
67    /// signature using the provided private key. The signature covers all challenge data
68    /// to prevent tampering.
69    ///
70    /// # Arguments
71    /// * `website_id`:      The identifier of the website.
72    /// * `difficulty`:      The target difficulty (expected number of attempts).
73    /// * `private_key`:     Ed25519 private key for signing the challenge.
74    /// * `public_key`:      Ed25519 public key corresponding to the private key.
75    ///
76    /// # Returns
77    /// * `Self`:            A new, properly signed IronShieldChallenge.
78    pub fn new(
79        website_id:  String,
80        difficulty:  u64,
81        private_key: SigningKey,
82        public_key:  [u8; 32],
83    ) -> Self {
84        let    random_nonce:   String = Self::generate_random_nonce();
85        let    created_time:      i64 = Self::generate_created_time();
86        let expiration_time:      i64 = created_time + 30_000; // 30-second expiration.
87        let challenge_param: [u8; 32] = Self::difficulty_to_challenge_param(difficulty);
88        
89        // Create the signing message from the challenge components
90        let signing_message = crate::crypto::create_signing_message(
91            &random_nonce,
92            created_time,
93            expiration_time,
94            &website_id,
95            &challenge_param,
96            &public_key
97        );
98
99        // Generate the signature using the reusable generate_signature function.
100        let challenge_signature: [u8; 64] = crate::crypto::generate_signature(&private_key, &signing_message)
101            .unwrap_or([0u8; 64]);
102
103        Self {
104            random_nonce,
105            created_time,
106            website_id,
107            expiration_time,
108            challenge_param,
109            recommended_attempts: Self::recommended_attempts(difficulty),
110            public_key,
111            challenge_signature,
112        }
113    }
114
115    /// Converts a difficulty value (expected number of attempts) to a challenge_param.
116    ///
117    /// The difficulty represents the expected number of hash attempts needed to find a valid nonce
118    /// where SHA256(random_nonce_bytes + nonce_bytes) < challenge_param.
119    ///
120    /// Since hash outputs are uniformly distributed over the 256-bit space, the relationship is:
121    /// challenge_param = 2^256 / difficulty.
122    ///
123    /// This function accurately calculates this for difficulties ranging from 1 to u64::MAX.
124    ///
125    /// # Arguments
126    /// * `difficulty`: Expected number of attempts (must be > 0).
127    ///
128    /// # Returns
129    /// * `[u8; 32]`: The challenge_param bytes in big-endian format.
130    ///
131    /// # Panics
132    /// * Panics if difficulty is 0
133    ///
134    /// # Examples
135    /// * difficulty = 1 ->         challenge_param = [0xFF; 32] (very easy, ~100% chance).
136    /// * difficulty = 2 ->         challenge_param = [0x80, 0x00, ...] (MSB set, ~50% chance).
137    /// * difficulty = 10,000 ->    challenge_param ≈ 2^242.7 (realistic difficulty).
138    /// * difficulty = 1,000,000 -> challenge_param ≈ 2^236.4 (higher difficulty).
139    pub fn difficulty_to_challenge_param(difficulty: u64) -> [u8; 32] {
140        if difficulty == 0 {
141            panic!("Difficulty cannot be zero.")
142        }
143
144        if difficulty == 1 {
145            return [MAX_BYTE_VALUE; ARRAY_SIZE];
146        }
147
148        // Calculate target exponent: 256 - log2(difficulty).
149        // This gives us the exponent of 2 in the result
150        // 2^256 / difficulty ~= 2^(target_exponent).
151        let log2_difficulty: f64 = (difficulty as f64).log2();
152        let target_exponent: f64 = HASH_BITS as f64 - log2_difficulty;
153
154        if target_exponent <= 0.0 { // Result would be less than 1, return min value.
155            return Self::create_minimal_challenge_param()
156        }
157
158        if target_exponent >= HASH_BITS as f64 {
159            return [MAX_BYTE_VALUE; ARRAY_SIZE];
160        }
161
162        // Round to the nearest whole number for bit positioning.
163        let bit_position: usize = target_exponent.round() as usize;
164
165        if bit_position >= HASH_BITS {
166            return [MAX_BYTE_VALUE; ARRAY_SIZE];
167        }
168
169        Self::create_challenge_param_with_bit_set(bit_position)
170    }
171
172    /// Creates a challenge parameter with the minimal
173    /// possible value (LSB set).
174    ///
175    /// # Returns
176    /// * `[u8; 32]`: Array with only the least significant
177    ///               bit set.
178    fn create_minimal_challenge_param() -> [u8; 32] {
179        let mut result: [u8; 32] = [0u8; ARRAY_SIZE];
180        result[LSB_INDEX] = LSB_VALUE;
181        result
182    }
183
184    /// Creates a challenge parameter with a specific bit
185    /// set.
186    ///
187    /// For a big-endian byte array, bit N is located at:
188    /// - byte index: (255 - N) / 8
189    /// - bit index within byte: 7 - ((255 - N) % 8)
190    ///
191    /// # Arguments
192    /// * `bit_position`: The bit position to set (0 = LSB, 255 = MSB).
193    ///
194    /// # Returns
195    /// * `[u8; 32]`: Array with the specified bit set.
196    fn create_challenge_param_with_bit_set(
197        bit_position: usize
198    ) -> [u8; 32] {
199        let mut result: [u8; 32] = [0u8; ARRAY_SIZE];
200
201        // Calculate byte and bit indices for big-endian format.
202        let byte_index: usize = (MAX_BIT_POSITION - bit_position) / BITS_PER_BYTE;
203        let  bit_index: usize = BITS_PER_BYTE_MASK - ((MAX_BIT_POSITION - bit_position) % BITS_PER_BYTE);
204
205        if byte_index < ARRAY_SIZE {
206            result[byte_index] = 1u8 << bit_index;
207        } else { // Fallback on edge case: set the least significant bit.
208            return Self::create_minimal_challenge_param()
209        }
210
211        result
212    }
213
214    /// # Returns
215    /// * `bool`: `true` if the challenge is expired,
216    ///           `false` otherwise.
217    pub fn is_expired(&self) -> bool {
218        Utc::now().timestamp_millis() > self.expiration_time
219    }
220
221    /// # Returns
222    /// * `i64`: `created_time` **plus** 30 seconds.
223    pub fn time_until_expiration(&self) -> i64 {
224        self.expiration_time - Utc::now().timestamp_millis()
225    }
226
227    /// # Returns
228    /// * `i64`: The current time in millis.
229    pub fn generate_created_time() -> i64 {
230        Utc::now().timestamp_millis()
231    }
232
233    /// # Returns
234    /// * `String`: A random hex-encoded value.
235    pub fn generate_random_nonce() -> String {
236        hex::encode(&rand::random::<[u8; 16]>())
237    }
238
239    /// Returns the recommended number of attempts to expect for a given difficulty.
240    ///
241    /// This provides users with a realistic expectation of how many attempts they might need.
242    /// Since the expected value is equal to the difficulty, we return 2x the difficulty
243    /// to give users a reasonable upper bound for planning purposes.
244    ///
245    /// # Arguments
246    /// * `difficulty`: The target difficulty (expected number of attempts)
247    ///
248    /// # Returns
249    /// * `u64`: Recommended number of attempts (2x the difficulty)
250    ///
251    /// # Examples
252    /// * difficulty = 1,000 → recommended_attempts = 2,000
253    /// * difficulty = 50,000 → recommended_attempts = 100,000
254    pub fn recommended_attempts(difficulty: u64) -> u64 {
255        difficulty.saturating_mul(2)
256    }
257
258    /// Concatenates the challenge data into a string.
259    ///
260    /// Concatenates:
261    /// * `random_nonce`     as a string.
262    /// * `created_time`     as `i64`.
263    /// * `expiration_time`  as `i64`.
264    /// * `website_id`       as a string.
265    /// * `public_key`       as a lowercase hex string.
266    /// * `challenge_params` as a lowercase hex string.
267    pub fn concat_struct(&self) -> String {
268        format!(
269            "{}|{}|{}|{}|{}|{}|{}|{}",
270            self.random_nonce,
271            self.created_time,
272            self.expiration_time,
273            self.website_id,
274            // We need to encode the byte arrays for format! to work.
275            hex::encode(self.challenge_param),
276            self.recommended_attempts,
277            hex::encode(self.public_key),
278            hex::encode(self.challenge_signature)
279        )
280    }
281
282    /// Creates an `IronShieldChallenge` from a concatenated string.
283    ///
284    /// This function reverses the operation of
285    /// `IronShieldChallenge::concat_struct`.
286    /// Expects a string in the format:
287    /// "random_nonce|created_time|expiration_time|website_id|challenge_params|public_key|challenge_signature"
288    ///
289    /// # Arguments
290    ///
291    /// * `concat_str`: The concatenated string to parse, typically
292    ///                 generated by `concat_struct()`.
293    ///
294    /// # Returns
295    ///
296    /// * `Result<Self, String>`: A result containing the parsed
297    ///                           `IronShieldChallenge` or an
298    ///                           error message if parsing fails.
299    pub fn from_concat_struct(concat_str: &str) -> Result<Self, String> {
300        let parts: Vec<&str> = concat_str.split('|').collect();
301
302        if parts.len() != 8 {
303            return Err(format!("Expected 8 parts, got {}", parts.len()));
304        }
305
306        let random_nonce: String = parts[0].to_string();
307
308        let created_time: i64 = parts[1].parse::<i64>()
309            .map_err(|_| "Failed to parse created_time as i64")?;
310
311        let expiration_time: i64 = parts[2].parse::<i64>()
312            .map_err(|_| "Failed to parse expiration_time as i64")?;
313
314        let website_id: String = parts[3].to_string();
315
316        let challenge_param_bytes: Vec<u8> = hex::decode(parts[4])
317            .map_err(|_| "Failed to decode challenge_params hex string")?;
318        let challenge_param: [u8; 32] = challenge_param_bytes
319            .try_into()
320            .map_err(|_| "Challenge params must be exactly 32 bytes")?;
321
322        let recommended_attempts: u64 = parts[5].parse::<u64>()
323            .map_err(|_| "Failed to parse recommended_attempts as u64")?;
324
325        let public_key_bytes: Vec<u8> = hex::decode(parts[6])
326            .map_err(|_| "Failed to decode public_key hex string")?;
327        let public_key: [u8; 32] = public_key_bytes.try_into()
328            .map_err(|_| "Public key must be exactly 32 bytes")?;
329
330        let signature_bytes: Vec<u8> = hex::decode(parts[7])
331            .map_err(|_| "Failed to decode challenge_signature hex string")?;
332        let challenge_signature: [u8; 64] = signature_bytes
333            .try_into()
334            .map_err(|_| "Signature must be exactly 64 bytes")?;
335
336        Ok(Self {
337            random_nonce,
338            created_time,
339            expiration_time,
340            website_id,
341            challenge_param,
342            recommended_attempts,
343            public_key,
344            challenge_signature,
345        })
346    }
347
348    /// Encodes the challenge as a base64url string for HTTP header transport.
349    ///
350    /// This method concatenates all challenge fields using the established `|` delimiter
351    /// format, and then base64url-encodes the result for safe transport in HTTP headers.
352    ///
353    /// # Returns
354    /// * `String`: Base64url-encoded string ready for HTTP header use.
355    ///
356    /// # Example
357    /// ```
358    /// use ironshield_types::IronShieldChallenge;
359    /// use ed25519_dalek::SigningKey;
360    /// let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
361    /// let challenge = IronShieldChallenge::new(
362    ///     "test_website".to_string(),
363    ///     100_000,
364    ///     dummy_key,
365    ///     [0x34; 32],
366    /// );
367    /// let header_value = challenge.to_base64url_header();
368    /// // Use header_value in HTTP header: "X-IronShield-Challenge-Data: {header_value}"
369    /// ```
370    pub fn to_base64url_header(&self) -> String {
371        crate::serde_utils::concat_struct_base64url_encode(&self.concat_struct())
372    }
373
374    /// Decodes a base64url-encoded challenge from an HTTP header.
375    ///
376    /// This method reverses the `to_base64url_header()` operation by first base64url-decoding
377    /// the input string and then parsing it using the established `|` delimiter format.
378    ///
379    /// # Arguments
380    /// * `encoded_header`: The base64url-encoded string from the HTTP header.
381    ///
382    /// # Returns
383    /// * `Result<Self, String>`: Decoded challenge or detailed error message.
384    ///
385    /// # Example
386    /// ```
387    /// use ironshield_types::IronShieldChallenge;
388    /// use ed25519_dalek::SigningKey;
389    /// // Create a challenge and encode it
390    /// let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
391    /// let original = IronShieldChallenge::new(
392    ///     "test_website".to_string(),
393    ///     100_000,
394    ///     dummy_key,
395    ///     [0x34; 32],
396    /// );
397    /// let header_value = original.to_base64url_header();
398    /// // Decode it back
399    /// let decoded = IronShieldChallenge::from_base64url_header(&header_value).unwrap();
400    /// assert_eq!(original.random_nonce, decoded.random_nonce);
401    /// ```
402    pub fn from_base64url_header(encoded_header: &str) -> Result<Self, String> {
403        // Decode using the existing serde_utils function.
404        let concat_str: String = crate::serde_utils::concat_struct_base64url_decode(encoded_header.to_string())?;
405
406        // Parse using the existing concat_struct format.
407        Self::from_concat_struct(&concat_str)
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn test_difficulty_to_challenge_param_basic_cases() {
417        // Test a very easy case.
418        let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(1);
419        assert_eq!(challenge_param, [0xFF; 32]);
420
421        // Test the exact powers of 2.
422        let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(2);
423        let expected: [u8; 32] = {
424            let mut arr: [u8; 32] = [0x00; 32];
425            arr[0] = 0x80; // 2^255
426            arr
427        };
428        assert_eq!(challenge_param, expected);
429
430        let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(4);
431        let expected: [u8; 32] = {
432            let mut arr: [u8; 32] = [0x00; 32];
433            arr[0] = 0x40; // 2^254
434            arr
435        };
436        assert_eq!(challenge_param, expected);
437
438        let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(256);
439        let expected: [u8; 32] = {
440            let mut arr: [u8; 32] = [0x00; 32];
441            arr[0] = 0x01; // 2^248
442            arr
443        };
444        assert_eq!(challenge_param, expected);
445    }
446
447    #[test]
448    fn test_difficulty_to_challenge_param_realistic_range() {
449        // Test difficulties in the expected range: 10,000 to 10,000,000.
450
451        // difficulty = 10,000 ≈ 2^13.29, so the result ≈ 2^242.71 → rounds to 2^243.
452        let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(10_000);
453        // Should have bit 243 set (byte 1, bit 3).
454        assert_eq!(challenge_param[0], 0x00);
455        assert_eq!(challenge_param[1], 0x08); // bit 3 = 0x08
456
457        // difficulty = 50,000 ≈ 2^15.61, so the result ≈ 2^240.39 → rounds to 2^240.
458        let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(50_000);
459        assert_eq!(challenge_param[0], 0x00);
460        assert_eq!(challenge_param[1], 0x01); // bit 0 = 0x01
461
462        // difficulty = 100,000 ≈ 2^16.61, so the result ≈ 2^239.39 → rounds to 2^239.
463        let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(100_000);
464        assert_eq!(challenge_param[0], 0x00);
465        assert_eq!(challenge_param[1], 0x00);
466        assert_eq!(challenge_param[2], 0x80); // bit 7 of byte 2
467
468        // difficulty = 1,000,000 ≈ 2^19.93, so the result ≈ 2^236.07 → rounds to 2^236.
469        let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(1_000_000);
470        assert_eq!(challenge_param[0], 0x00);
471        assert_eq!(challenge_param[1], 0x00);
472        assert_eq!(challenge_param[2], 0x10); // bit 4 of byte 2
473
474        // difficulty = 10,000,000 ≈ 2^23.25, so the result ≈ 2^232.75 → rounds to 2^233.
475        let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(10_000_000);
476        assert_eq!(challenge_param[0], 0x00);
477        assert_eq!(challenge_param[1], 0x00);
478        assert_eq!(challenge_param[2], 0x02); // bit 1 of byte 2
479    }
480
481    #[test]
482    fn test_difficulty_to_challenge_param_ordering() {
483        // Test that higher difficulties produce smaller challenge_params.
484        let difficulties: [u64; 9] = [1000, 5000, 10_000, 50_000, 100_000, 500_000, 1_000_000, 5_000_000, 10_000_000];
485        let mut challenge_params = Vec::new();
486
487        for &difficulty in &difficulties {
488            challenge_params.push(IronShieldChallenge::difficulty_to_challenge_param(difficulty));
489        }
490
491        // Verify that challenge_params are in descending order (higher difficulty = smaller param).
492        for i in 1..challenge_params.len() {
493            assert!(
494                challenge_params[i-1] > challenge_params[i],
495                "Challenge param for difficulty {} should be larger than for difficulty {}",
496                difficulties[i-1], difficulties[i]
497            );
498        }
499    }
500
501    #[test]
502    fn test_difficulty_to_challenge_param_precision() {
503        // Test that similar difficulties produce appropriately similar results.
504        let base_difficulty: u64 = 100_000;
505        let base_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(base_difficulty);
506
507        // Small variations in difficulty will round to the same or nearby bit positions.
508        let similar_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(100_001);
509
510        // With rounding, very similar difficulties might produce the same result.
511        // The key test is that larger difficulties produce smaller or equal challenge_params.
512        assert!(base_param >= similar_param); // Should be the same or slightly larger.
513
514        // Test that larger differences produce measurably different results.
515        let much_different_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(200_000);
516        assert!(base_param > much_different_param);
517
518        // Test that the ordering is consistent for larger changes.
519        let big_different_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(400_000);
520        assert!(much_different_param > big_different_param);
521    }
522
523    #[test]
524    fn test_difficulty_to_challenge_param_powers_of_10() {
525        // Test various powers of 10.
526        let difficulties: [u64; 6] = [10, 100, 1_000, 10_000, 100_000, 1_000_000];
527
528        for &difficulty in &difficulties {
529            let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(difficulty);
530
531            // Should not be all zeros or all FFs (except for difficulty 1).
532            assert_ne!(challenge_param, [0x00; 32]);
533            assert_ne!(challenge_param, [0xFF; 32]);
534
535            // Should have a reasonable number of leading zeros.
536            let leading_zero_bytes: usize = challenge_param.iter().take_while(|&&b| b == 0).count();
537            assert!(leading_zero_bytes < 32, "Too many leading zero bytes for difficulty {}", difficulty);
538
539            // Should not be too small (no more than 28 leading zero bytes for this range)
540            assert!(leading_zero_bytes < 28, "Challenge param too small for difficulty {}", difficulty);
541        }
542    }
543
544    #[test]
545    fn test_difficulty_to_challenge_param_mathematical_properties() {
546        // Test mathematical properties of the algorithm.
547
548        // For difficulty D1 and D2 where D2 = 2 * D1,
549        // challenge_param(D1) should be approximately 2 * challenge_param(D2)
550        let d1: u64 = 50_000;
551        let d2: u64 = 100_000; // 2 * d1
552
553        let param1: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(d1);
554        let param2: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(d2);
555
556        // Convert to u128 for comparison (taking first 16 bytes).
557        let val1: u128 = u128::from_be_bytes(param1[0..16].try_into().unwrap());
558        let val2: u128 = u128::from_be_bytes(param2[0..16].try_into().unwrap());
559
560        // val1 should be approximately 2 * val2 (within reasonable tolerance).
561        let ratio: f64 = val1 as f64 / val2 as f64;
562        assert!(ratio > 1.8 && ratio < 2.2, "Ratio should be close to 2.0, got {}", ratio);
563    }
564
565    #[test]
566    fn test_difficulty_to_challenge_param_edge_cases() {
567        // Test zero difficulty panics.
568        let result = std::panic::catch_unwind(|| {
569            IronShieldChallenge::difficulty_to_challenge_param(0);
570        });
571        assert!(result.is_err());
572
573        // Test very high difficulty produces a small value.
574        let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(u64::MAX);
575        assert_ne!(challenge_param, [0xFF; 32]);
576        assert_ne!(challenge_param, [0; 32]);
577
578        // Test moderately high difficulties.
579        let high_difficulty: u64 = 1u64 << 40; // 2^40
580        let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(high_difficulty);
581        assert_ne!(challenge_param, [0; 32]);
582        assert_ne!(challenge_param, [0xFF; 32]);
583    }
584
585    #[test]
586    fn test_difficulty_to_challenge_param_consistency() {
587        // Test that the function produces consistent results.
588        let test_difficulties: [u64; 13] = [
589            10_000, 25_000, 50_000, 75_000, 100_000,
590            250_000, 500_000, 750_000, 1_000_000,
591            2_500_000, 5_000_000, 7_500_000, 10_000_000
592        ];
593
594        for &difficulty in &test_difficulties {
595            let param1: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(difficulty);
596            let param2: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(difficulty);
597            assert_eq!(param1, param2, "Function should be deterministic for difficulty {}", difficulty);
598
599            // Test that the challenge param is reasonable.
600            assert_ne!(param1, [0x00; 32]);
601            assert_ne!(param1, [0xFF; 32]);
602        }
603    }
604
605    #[test]
606    fn test_recommended_attempts() {
607        // Test recommended_attempts function
608        assert_eq!(IronShieldChallenge::recommended_attempts(1000), 2000);
609        assert_eq!(IronShieldChallenge::recommended_attempts(50000), 100000);
610        assert_eq!(IronShieldChallenge::recommended_attempts(0), 0);
611
612        // Test overflow protection
613        assert_eq!(IronShieldChallenge::recommended_attempts(u64::MAX), u64::MAX);
614
615        // Test realistic range
616        assert_eq!(IronShieldChallenge::recommended_attempts(10_000), 20_000);
617        assert_eq!(IronShieldChallenge::recommended_attempts(1_000_000), 2_000_000);
618    }
619
620    #[test]
621    fn test_base64url_header_encoding_roundtrip() {
622        // Create a dummy challenge for testing.
623        let private_key = SigningKey::from_bytes(&[0; 32]);
624        let public_key = private_key.verifying_key().to_bytes();
625        let original_challenge = IronShieldChallenge::new(
626            "test-site".to_string(),
627            100_000,
628            private_key,
629            public_key,
630        );
631
632        // Encode and decode the challenge.
633        let encoded = original_challenge.to_base64url_header();
634        let decoded_challenge = IronShieldChallenge::from_base64url_header(&encoded)
635            .expect("Failed to decode header");
636
637        // Verify that the fields match.
638        assert_eq!(original_challenge.random_nonce, decoded_challenge.random_nonce);
639        assert_eq!(original_challenge.created_time, decoded_challenge.created_time);
640        assert_eq!(original_challenge.expiration_time, decoded_challenge.expiration_time);
641        assert_eq!(original_challenge.website_id, decoded_challenge.website_id);
642        assert_eq!(original_challenge.challenge_param, decoded_challenge.challenge_param);
643        assert_eq!(original_challenge.public_key, decoded_challenge.public_key);
644        assert_eq!(original_challenge.challenge_signature, decoded_challenge.challenge_signature);
645    }
646
647    #[test]
648    fn test_base64url_header_invalid_data() {
649        // Test invalid base64url.
650        let result: Result<IronShieldChallenge, String> = IronShieldChallenge::from_base64url_header("invalid-base64!");
651        assert!(result.is_err());
652        assert!(result.unwrap_err().contains("Base64 decode error"));
653
654        // Test valid base64url but invalid concatenated format.
655        use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
656        let invalid_format: String = URL_SAFE_NO_PAD.encode(b"not_enough_parts");
657        let result: Result<IronShieldChallenge, String> = IronShieldChallenge::from_base64url_header(&invalid_format);
658        assert!(result.is_err());
659        assert!(result.unwrap_err().contains("Expected 8 parts"));
660    }
661
662    #[test]
663    fn test_difficulty_range_boundaries() {
664        // Test around the specified range boundaries (10,000 to 10,000,000)
665        let min_difficulty = 10_000;
666        let max_difficulty = 10_000_000;
667
668        let min_param = IronShieldChallenge::difficulty_to_challenge_param(min_difficulty);
669        let max_param = IronShieldChallenge::difficulty_to_challenge_param(max_difficulty);
670
671        // Min difficulty should produce a larger challenge_param than max difficulty.
672        assert!(min_param > max_param);
673
674        // Both should be reasonable values
675        assert_ne!(min_param, [0x00; 32]);
676        assert_ne!(min_param, [0xFF; 32]);
677        assert_ne!(max_param, [0x00; 32]);
678        assert_ne!(max_param, [0xFF; 32]);
679
680        // Test values slightly outside the range
681        let below_min = IronShieldChallenge::difficulty_to_challenge_param(9_999);
682        let above_max = IronShieldChallenge::difficulty_to_challenge_param(10_000_001);
683
684        // With rounding, very close values might produce the same result
685        assert!(below_min >= min_param); // Should be the same or larger
686        assert!(above_max <= max_param); // Should be the same or smaller
687    }
688
689    #[test]
690    fn test_from_concat_struct_edge_cases() {
691        // Test with all zero values
692        let valid_32_byte_hex = "0000000000000000000000000000000000000000000000000000000000000000";
693        assert_eq!(valid_32_byte_hex.len(), 64, "32-byte hex string should be exactly 64 characters");
694        let valid_64_byte_hex = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
695        assert_eq!(valid_64_byte_hex.len(), 128, "64-byte hex string should be exactly 128 characters");
696
697        let input = format!("test_nonce|1000000|1030000|test_website|{}|0|{}|{}",
698                            valid_32_byte_hex, valid_32_byte_hex, valid_64_byte_hex);
699        let result = IronShieldChallenge::from_concat_struct(&input);
700
701        assert!(result.is_ok(), "Should parse valid zero-value data");
702        let parsed = result.unwrap();
703        assert_eq!(parsed.random_nonce, "test_nonce");
704        assert_eq!(parsed.created_time, 1000000);
705        assert_eq!(parsed.expiration_time, 1030000);
706        assert_eq!(parsed.website_id, "test_website");
707        assert_eq!(parsed.challenge_param, [0u8; 32]);
708        assert_eq!(parsed.recommended_attempts, 0);
709        assert_eq!(parsed.public_key, [0u8; 32]);
710        assert_eq!(parsed.challenge_signature, [0u8; 64]);
711
712        // Test with all max values (0xFF)
713        let all_f_32_hex = "f".repeat(64);
714        assert_eq!(all_f_32_hex.len(), 64, "All F's 32-byte hex string should be exactly 64 characters");
715        let all_f_64_hex = "f".repeat(128);
716        assert_eq!(all_f_64_hex.len(), 128, "All F's 64-byte hex string should be exactly 128 characters");
717
718        let input = format!("max_nonce|{}|{}|max_website|{}|{}|{}|{}",
719                            i64::MAX, i64::MAX, all_f_32_hex, u64::MAX, all_f_32_hex, all_f_64_hex);
720        let result = IronShieldChallenge::from_concat_struct(&input);
721
722        assert!(result.is_ok(), "Should parse valid max-value data");
723        let parsed = result.unwrap();
724        assert_eq!(parsed.random_nonce, "max_nonce");
725        assert_eq!(parsed.created_time, i64::MAX);
726        assert_eq!(parsed.expiration_time, i64::MAX);
727        assert_eq!(parsed.website_id, "max_website");
728        assert_eq!(parsed.challenge_param, [0xffu8; 32]);
729        assert_eq!(parsed.recommended_attempts, u64::MAX);
730        assert_eq!(parsed.public_key, [0xffu8; 32]);
731        assert_eq!(parsed.challenge_signature, [0xffu8; 64]);
732    }
733}