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