Skip to main content

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