ironshield_types/
response.rs

1use serde::{Deserialize, Serialize};
2use crate::IronShieldChallenge;
3
4/// IronShield Challenge Response structure
5/// 
6/// * `solved_challenge`: The complete original IronShieldChallenge that was solved.
7/// * `solution`:         The nonce solution found by the proof-of-work algorithm.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct IronShieldChallengeResponse {
10    pub solved_challenge: IronShieldChallenge,
11    pub solution:         i64,
12}
13
14impl IronShieldChallengeResponse {
15    /// Constructor for creating a new `IronShieldChallengeResponse` instance.
16    /// 
17    /// # Arguments
18    /// * `solved_challenge`: The solved ironshield challenge.
19    /// * `solution`:         The random nonce that correlates with `solved_challenge`.
20    /// 
21    /// # Returns
22    /// * `Self`:             A new correlating response with the challenge and its 
23    ///                       deciphered nonce. 
24    pub fn new(
25        solved_challenge: IronShieldChallenge, 
26        solution: i64
27    ) -> Self {
28        Self {
29            solved_challenge,
30            solution,
31        }
32    }
33
34    /// Concatenates the response data into a string.
35    ///
36    /// Concatenates:
37    /// - `solved_challenge`: As its concatenated string representation.
38    /// - `solution`:         As a string.
39    pub fn concat_struct(&self) -> String {
40        format!(
41            "{}|{}",
42            self.solved_challenge.concat_struct(),
43            self.solution
44        )
45    }
46
47    /// Creates an `IronShieldChallengeResponse` from a concatenated string.
48    ///
49    /// This function reverses the operation of
50    /// `IronShieldChallengeResponse::concat_struct`.
51    /// Expects a string in the format: "challenge_concat_string|solution".
52    ///
53    /// # Arguments
54    /// * `concat_string`: The concatenated string to parse, typically
55    ///                    generated by `concat_struct()`.
56    ///
57    /// # Returns
58    /// * `Result<Self, String>`: A result containing the parsed 
59    ///                           `IronShieldChallengeResponse`
60    ///                           or an error message if parsing fails.
61    pub fn from_concat_struct(concat_string: &str) -> Result<Self, String> {
62        // Split on the last '|' to separate challenge from solution
63        let last_pipe_pos = concat_string.rfind('|')
64            .ok_or("Expected at least one '|' separator")?;
65        
66        let challenge_part = &concat_string[..last_pipe_pos];
67        let solution_part = &concat_string[last_pipe_pos + 1..];
68        
69        let solved_challenge = IronShieldChallenge::from_concat_struct(challenge_part)?;
70        let solution = solution_part.parse::<i64>()
71            .map_err(|_| "Failed to parse solution as i64")?;
72
73        Ok(Self {
74            solved_challenge,
75            solution,
76        })
77    }
78
79    /// Encodes the response as a base64url string for HTTP header transport.
80    /// 
81    /// This method concatenates all response fields using the established `|` delimiter
82    /// format, and then base64url-encodes the result for safe transport in HTTP headers.
83    /// 
84    /// # Returns
85    /// * `String` - Base64url-encoded string ready for HTTP header use
86    /// 
87    /// # Example
88    /// ```
89    /// use ironshield_types::{IronShieldChallengeResponse, IronShieldChallenge, SigningKey};
90    /// let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
91    /// let challenge = IronShieldChallenge::new("test".to_string(), 100_000, dummy_key, [0x34; 32]);
92    /// let response = IronShieldChallengeResponse::new(challenge, 12345);
93    /// let header_value = response.to_base64url_header();
94    /// // Use header_value in HTTP header: "X-IronShield-Challenge-Response: {header_value}"
95    /// ```
96    pub fn to_base64url_header(&self) -> String {
97        crate::serde_utils::concat_struct_base64url_encode(&self.concat_struct())
98    }
99    
100    /// Decodes a base64url-encoded response from an HTTP header.
101    /// 
102    /// This method reverses the `to_base64url_header()` operation by first base64url-decoding
103    /// the input string and then parsing it using the established `|` delimiter format.
104    /// 
105    /// # Arguments
106    /// * `encoded_header` - The base64url-encoded string from the HTTP header
107    /// 
108    /// # Returns
109    /// * `Result<Self, String>` - Decoded response or detailed error message
110    /// 
111    /// # Example
112    /// ```
113    /// use ironshield_types::{IronShieldChallengeResponse, IronShieldChallenge, SigningKey};
114    /// // Create a response and encode it
115    /// let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
116    /// let challenge = IronShieldChallenge::new("test".to_string(), 100_000, dummy_key, [0x34; 32]);
117    /// let original = IronShieldChallengeResponse::new(challenge, 12345);
118    /// let header_value = original.to_base64url_header();
119    /// // Decode it back
120    /// let decoded = IronShieldChallengeResponse::from_base64url_header(&header_value).unwrap();
121    /// assert_eq!(original.solution, decoded.solution);
122    /// ```
123    pub fn from_base64url_header(encoded_header: &str) -> Result<Self, String> {
124        // Decode using the existing serde_utils function.
125        let concat_str: String = crate::serde_utils::concat_struct_base64url_decode(encoded_header.to_string())?;
126        
127        // Parse using the existing concat_struct format.
128        Self::from_concat_struct(&concat_str)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::SigningKey;
136
137    #[test]
138    fn test_response_base64url_header_encoding_roundtrip() {
139        // Create a test challenge and response.
140        let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
141        let challenge = IronShieldChallenge::new(
142            "test_website".to_string(),
143            100_000,
144            dummy_key,
145            [0x34; 32],
146        );
147        let response: IronShieldChallengeResponse = IronShieldChallengeResponse::new(challenge, 12345);
148
149        // Test base64url encoding and decoding.
150        let encoded: String = response.to_base64url_header();
151        let decoded: IronShieldChallengeResponse = IronShieldChallengeResponse::from_base64url_header(&encoded).unwrap();
152
153        // Verify all fields are preserved through a round-trip.
154        assert_eq!(response.solved_challenge.random_nonce, decoded.solved_challenge.random_nonce);
155        assert_eq!(response.solved_challenge.website_id, decoded.solved_challenge.website_id);
156        assert_eq!(response.solved_challenge.challenge_param, decoded.solved_challenge.challenge_param);
157        assert_eq!(response.solved_challenge.public_key, decoded.solved_challenge.public_key);
158        assert_eq!(response.solved_challenge.challenge_signature, decoded.solved_challenge.challenge_signature);
159        assert_eq!(response.solution, decoded.solution);
160    }
161
162    #[test]
163    fn test_response_base64url_header_invalid_data() {
164        // Test invalid base64url.
165        let result: Result<IronShieldChallengeResponse, String> = IronShieldChallengeResponse::from_base64url_header("invalid-base64!");
166        assert!(result.is_err());
167        assert!(result.unwrap_err().contains("Base64 decode error"));
168
169        // Test valid base64url but invalid concatenated format.
170        use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
171        let invalid_format: String = URL_SAFE_NO_PAD.encode(b"only_one_part");
172        let result: Result<IronShieldChallengeResponse, String> = IronShieldChallengeResponse::from_base64url_header(&invalid_format);
173        assert!(result.is_err());
174        assert!(result.unwrap_err().contains("Expected at least one '|' separator"));
175    }
176
177    #[test]
178    fn test_concat_struct() {
179        let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
180        let challenge = IronShieldChallenge::new(
181            "test_website".to_string(),
182            100_000,
183            dummy_key,
184            [0x34; 32],
185        );
186        let response = IronShieldChallengeResponse::new(challenge.clone(), 42);
187        let concat = response.concat_struct();
188        let expected = format!("{}|{}", challenge.concat_struct(), 42);
189        assert_eq!(concat, expected);
190    }
191
192    #[test]
193    fn test_from_concat_struct() {
194        let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
195        let challenge = IronShieldChallenge::new(
196            "test_website".to_string(),
197            100_000,
198            dummy_key,
199            [0x34; 32],
200        );
201        let concat = format!("{}|{}", challenge.concat_struct(), 42);
202        let response = IronShieldChallengeResponse::from_concat_struct(&concat).unwrap();
203        assert_eq!(response.solved_challenge.website_id, challenge.website_id);
204        assert_eq!(response.solved_challenge.challenge_param, challenge.challenge_param);
205        assert_eq!(response.solution, 42);
206    }
207
208    #[test]
209    fn test_from_concat_struct_edge_cases() {
210        let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
211        let challenge = IronShieldChallenge::new(
212            "test_website".to_string(),
213            1,
214            dummy_key,
215            [0x00; 32],
216        );
217        
218        // Test with negative solution
219        let concat = format!("{}|{}", challenge.concat_struct(), -1);
220        let result = IronShieldChallengeResponse::from_concat_struct(&concat);
221        assert!(result.is_ok());
222        let parsed = result.unwrap();
223        assert_eq!(parsed.solution, -1);
224        
225        // Test with zero solution  
226        let concat = format!("{}|{}", challenge.concat_struct(), 0);
227        let result = IronShieldChallengeResponse::from_concat_struct(&concat);
228        assert!(result.is_ok());
229        let parsed = result.unwrap();
230        assert_eq!(parsed.solution, 0);
231        
232        // Test with large solution
233        let concat = format!("{}|{}", challenge.concat_struct(), i64::MAX);
234        let result = IronShieldChallengeResponse::from_concat_struct(&concat);
235        assert!(result.is_ok());
236        let parsed = result.unwrap();
237        assert_eq!(parsed.solution, i64::MAX);
238    }
239
240    #[test]
241    fn test_from_concat_struct_error_cases() {
242        // Test with no pipe separator
243        let result = IronShieldChallengeResponse::from_concat_struct("no_pipe_separator");
244        assert!(result.is_err());
245        assert!(result.unwrap_err().contains("Expected at least one '|' separator"));
246        
247        // Test with invalid solution
248        let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
249        let challenge = IronShieldChallenge::new(
250            "test_website".to_string(),
251            1,
252            dummy_key,
253            [0x00; 32],
254        );
255        let concat = format!("{}|{}", challenge.concat_struct(), "not_a_number");
256        let result = IronShieldChallengeResponse::from_concat_struct(&concat);
257        assert!(result.is_err());
258        assert!(result.unwrap_err().contains("Failed to parse solution as i64"));
259    }
260}