ironshield_types/
response.rs

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