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#[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 #[cfg_attr(feature = "openapi", schema(example = "a6e5f14c9622c88af274ec7247f028eb"))]
50 pub random_nonce: String,
51 #[cfg_attr(feature = "openapi", schema(example = 1755401345880i64))]
53 pub created_time: i64,
54 #[cfg_attr(feature = "openapi", schema(example = 1755401375880i64))]
56 pub expiration_time: i64,
57 #[cfg_attr(feature = "openapi", schema(example = "https://example.com"))]
59 pub website_id: String,
60 #[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 #[cfg_attr(feature = "openapi", schema(example = 400000000u64))]
69 pub recommended_attempts: u64,
70 #[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 #[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 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; let challenge_param: [u8; 32] = Self::difficulty_to_challenge_param(difficulty);
111
112 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 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 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 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 { 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 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 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 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 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 { return Self::create_minimal_challenge_param()
232 }
233
234 result
235 }
236
237 pub fn is_expired(&self) -> bool {
241 Utc::now().timestamp_millis() > self.expiration_time
242 }
243
244 pub fn time_until_expiration(&self) -> i64 {
247 self.expiration_time - Utc::now().timestamp_millis()
248 }
249
250 pub fn generate_created_time() -> i64 {
253 Utc::now().timestamp_millis()
254 }
255
256 pub fn generate_random_nonce() -> String {
259 hex::encode(&rand::random::<[u8; 16]>())
260 }
261
262 pub fn recommended_attempts(difficulty: u64) -> u64 {
278 difficulty.saturating_mul(2)
279 }
280
281 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 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 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 pub fn to_base64url_header(&self) -> String {
394 crate::serde_utils::concat_struct_base64url_encode(&self.concat_struct())
395 }
396
397 pub fn from_base64url_header(encoded_header: &str) -> Result<Self, String> {
426 let concat_str: String = crate::serde_utils::concat_struct_base64url_decode(encoded_header.to_string())?;
428
429 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 let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(1);
442 assert_eq!(challenge_param, [0xFF; 32]);
443
444 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; 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; 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; arr
466 };
467 assert_eq!(challenge_param, expected);
468 }
469
470 #[test]
471 fn test_difficulty_to_challenge_param_realistic_range() {
472 let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(10_000);
476 assert_eq!(challenge_param[0], 0x00);
478 assert_eq!(challenge_param[1], 0x08); 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); 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); 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); 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); }
503
504 #[test]
505 fn test_difficulty_to_challenge_param_ordering() {
506 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 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 let base_difficulty: u64 = 100_000;
528 let base_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(base_difficulty);
529
530 let similar_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(100_001);
532
533 assert!(base_param >= similar_param); let much_different_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(200_000);
539 assert!(base_param > much_different_param);
540
541 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 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 assert_ne!(challenge_param, [0x00; 32]);
556 assert_ne!(challenge_param, [0xFF; 32]);
557
558 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 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 let d1: u64 = 50_000;
574 let d2: u64 = 100_000; let param1: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(d1);
577 let param2: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(d2);
578
579 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 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 let result = std::panic::catch_unwind(|| {
592 IronShieldChallenge::difficulty_to_challenge_param(0);
593 });
594 assert!(result.is_err());
595
596 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 let high_difficulty: u64 = 1u64 << 40; 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 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 assert_ne!(param1, [0x00; 32]);
624 assert_ne!(param1, [0xFF; 32]);
625 }
626 }
627
628 #[test]
629 fn test_recommended_attempts() {
630 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 assert_eq!(IronShieldChallenge::recommended_attempts(u64::MAX), u64::MAX);
637
638 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 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 let encoded = original_challenge.to_base64url_header();
657 let decoded_challenge = IronShieldChallenge::from_base64url_header(&encoded)
658 .expect("Failed to decode header");
659
660 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 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 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 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 assert!(min_param > max_param);
696
697 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 let below_min = IronShieldChallenge::difficulty_to_challenge_param(9_999);
705 let above_max = IronShieldChallenge::difficulty_to_challenge_param(10_000_001);
706
707 assert!(below_min >= min_param); assert!(above_max <= max_param); }
711
712 #[test]
713 fn test_from_concat_struct_edge_cases() {
714 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 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}