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