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