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