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#[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 #[cfg_attr(feature = "openapi", schema(example = "a6e5f14c9622c88af274ec7247f028eb"))]
45 pub random_nonce: String,
46 #[cfg_attr(feature = "openapi", schema(example = 1755401345880i64))]
48 pub created_time: i64,
49 #[cfg_attr(feature = "openapi", schema(example = 1755401375880i64))]
51 pub expiration_time: i64,
52 #[cfg_attr(feature = "openapi", schema(example = "https://example.com"))]
54 pub website_id: String,
55 #[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 #[cfg_attr(feature = "openapi", schema(example = 400000000u64))]
64 pub recommended_attempts: u64,
65 #[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 #[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 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; let challenge_param: [u8; 32] = Self::difficulty_to_challenge_param(difficulty);
108
109 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 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 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 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 { 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 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 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 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 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 { return Self::create_minimal_challenge_param()
229 }
230
231 result
232 }
233
234 pub fn is_expired(&self) -> bool {
238 Utc::now().timestamp_millis() > self.expiration_time
239 }
240
241 pub fn time_until_expiration(&self) -> i64 {
244 self.expiration_time - Utc::now().timestamp_millis()
245 }
246
247 pub fn generate_created_time() -> i64 {
250 Utc::now().timestamp_millis()
251 }
252
253 pub fn generate_random_nonce() -> String {
256 hex::encode(&rand::random::<[u8; 16]>())
257 }
258
259 pub fn recommended_attempts(difficulty: u64) -> u64 {
275 difficulty.saturating_mul(2)
276 }
277
278 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 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 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 pub fn to_base64url_header(&self) -> String {
391 crate::serde_utils::concat_struct_base64url_encode(&self.concat_struct())
392 }
393
394 pub fn from_base64url_header(encoded_header: &str) -> Result<Self, String> {
423 let concat_str: String = crate::serde_utils::concat_struct_base64url_decode(encoded_header.to_string())?;
425
426 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 let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(1);
439 assert_eq!(challenge_param, [0xFF; 32]);
440
441 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; 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; 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; arr
463 };
464 assert_eq!(challenge_param, expected);
465 }
466
467 #[test]
468 fn test_difficulty_to_challenge_param_realistic_range() {
469 let challenge_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(10_000);
473 assert_eq!(challenge_param[0], 0x00);
475 assert_eq!(challenge_param[1], 0x08); 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); 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); 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); 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); }
500
501 #[test]
502 fn test_difficulty_to_challenge_param_ordering() {
503 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 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 let base_difficulty: u64 = 100_000;
525 let base_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(base_difficulty);
526
527 let similar_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(100_001);
529
530 assert!(base_param >= similar_param); let much_different_param: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(200_000);
536 assert!(base_param > much_different_param);
537
538 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 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 assert_ne!(challenge_param, [0x00; 32]);
553 assert_ne!(challenge_param, [0xFF; 32]);
554
555 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 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 let d1: u64 = 50_000;
571 let d2: u64 = 100_000; let param1: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(d1);
574 let param2: [u8; 32] = IronShieldChallenge::difficulty_to_challenge_param(d2);
575
576 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 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 let result = std::panic::catch_unwind(|| {
589 IronShieldChallenge::difficulty_to_challenge_param(0);
590 });
591 assert!(result.is_err());
592
593 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 let high_difficulty: u64 = 1u64 << 40; 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 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 assert_ne!(param1, [0x00; 32]);
621 assert_ne!(param1, [0xFF; 32]);
622 }
623 }
624
625 #[test]
626 fn test_recommended_attempts() {
627 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 assert_eq!(IronShieldChallenge::recommended_attempts(u64::MAX), u64::MAX);
634
635 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 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 let encoded = original_challenge.to_base64url_header();
654 let decoded_challenge = IronShieldChallenge::from_base64url_header(&encoded)
655 .expect("Failed to decode header");
656
657 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 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 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 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 assert!(min_param > max_param);
693
694 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 let below_min = IronShieldChallenge::difficulty_to_challenge_param(9_999);
702 let above_max = IronShieldChallenge::difficulty_to_challenge_param(10_000_001);
703
704 assert!(below_min >= min_param); assert!(above_max <= max_param); }
708
709 #[test]
710 fn test_from_concat_struct_edge_cases() {
711 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 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}