ironshield_types/
token.rs

1use chrono::Utc;
2use serde::{
3    Deserialize, 
4    Serialize
5};
6
7use crate::serde_utils::{
8    serialize_signature, 
9    deserialize_signature
10};
11
12/// IronShield Token structure
13///
14/// * `challenge_signature`:      The Ed25519 signature of the challenge.
15/// * `valid_for`:                The Unix timestamp in unix millis.
16/// * `public_key`:               The Ed25519 public key corresponding
17///                               to the central private key (32 bytes).
18/// * `authentication_signature`: The signature over (challenge_signature
19///                               || valid_for).
20#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct IronShieldToken {
23    #[serde(
24        serialize_with = "serialize_signature",
25        deserialize_with = "deserialize_signature"
26    )]
27    pub challenge_signature: [u8; 64],
28    pub valid_for:           i64,
29    pub public_key:          [u8; 32],
30    #[serde(
31        serialize_with = "serialize_signature",
32        deserialize_with = "deserialize_signature"
33    )]
34    pub auth_signature:      [u8; 64],
35}
36
37impl IronShieldToken {
38    pub fn new(
39        challenge_signature: [u8; 64],
40        valid_for:           i64,
41        public_key:          [u8; 32],
42        auth_signature:      [u8; 64],
43    ) -> Self {
44        Self {
45            challenge_signature,
46            valid_for,
47            public_key,
48            auth_signature,
49        }
50    }
51
52    /// # Returns
53    /// * `bool`: 
54    pub fn is_expired(&self) -> bool {
55        Utc::now().timestamp_millis() > self.valid_for
56    }
57
58    /// Concatenates the token data into a string.
59    ///
60    /// Concatenates:
61    /// - `challenge_signature`       as a lowercase hex string.
62    /// - `valid_for`:                as a string.
63    /// - `public_key`:               as a lowercase hex string.
64    /// - `authentication_signature`: as a lowercase hex string.
65    pub fn concat_struct(&self) -> String {
66        format!(
67            "{}|{}|{}|{}",
68            // Use of hex::encode to convert the arrays to hex strings
69            // "Encodes data as hex string using lowercase characters."
70            // Requirement of `format!`.
71            hex::encode(self.challenge_signature),
72            self.valid_for,
73            hex::encode(self.public_key),
74            hex::encode(self.auth_signature)
75        )
76    }
77
78    /// Creates an `IronShieldToken` from a concatenated string.
79    ///
80    /// This function reverses the operation of `IronShieldToken::concat_struct`.
81    /// Expects a string in the format:
82    /// "challenge_signature|valid_for|public_key|authentication_signature"
83    ///
84    /// # Arguments
85    ///
86    /// * `concat_str`: The concatenated string to parse, typically
87    ///                 generated by `concat_struct()`.
88    ///
89    /// # Returns
90    ///
91    /// * `Result<Self, String>`: A result containing the parsed
92    ///                           `IronShieldToken` or an error message
93    ///                           if parsing fails.
94    pub fn from_concat_struct(concat_str: &str) -> Result<Self, String> {
95        let parts: Vec<&str> = concat_str.split('|').collect();
96
97        if parts.len() != 4 {
98            return Err(format!("Expected 4 parts, got {}", parts.len()));
99        }
100
101        let challenge_signature_bytes = hex::decode(parts[0])
102            .map_err(|_| "Failed to decode challenge_signature hex string")?;
103        let challenge_signature: [u8; 64] = challenge_signature_bytes.try_into()
104            .map_err(|_| "Challenge signature must be exactly 64 bytes")?;
105
106        let valid_for = parts[1].parse::<i64>()
107            .map_err(|_| "Failed to parse valid_for as i64")?;
108
109        let public_key_bytes = hex::decode(parts[2])
110            .map_err(|_| "Failed to decode public_key hex string")?;
111        let public_key: [u8; 32] = public_key_bytes.try_into()
112            .map_err(|_| "Public key must be exactly 32 bytes")?;
113
114        let auth_signature_bytes = hex::decode(parts[3])
115            .map_err(|_| "Failed to decode authentication_signature hex string")?;
116        let authentication_signature: [u8; 64] = auth_signature_bytes.try_into()
117            .map_err(|_| "Authentication signature must be exactly 64 bytes")?;
118
119        Ok(Self {
120            challenge_signature,
121            valid_for,
122            public_key,
123            auth_signature: authentication_signature,
124        })
125    }
126
127    /// Encodes the response as a base64url string for HTTP header transport.
128    ///
129    /// This method concatenates all response fields using the established `|` delimiter
130    /// format, and then base64url-encodes the result for safe transport in HTTP headers.
131    ///
132    /// # Returns
133    /// * `String` - Base64url-encoded string ready for HTTP header use
134    ///
135    /// # Example
136    /// ```
137    /// use ironshield_types::IronShieldToken;
138    /// let response = IronShieldToken::new([0xAB; 64], 12345, [0x12; 32], [0x34; 64]);
139    /// let header_value = response.to_base64url_header();
140    /// // Use header_value in HTTP header: "X-IronShield-Challenge-Response: {header_value}"
141    /// ```
142    pub fn to_base64url_header(&self) -> String {
143        crate::serde_utils::concat_struct_base64url_encode(&self.concat_struct())
144    }
145
146    /// Decodes a base64url-encoded response from an HTTP header.
147    ///
148    /// This method reverses the `to_base64url_header()` operation by first base64url-decoding
149    /// the input string and then parsing it using the established `|` delimiter format.
150    ///
151    /// # Arguments
152    /// * `encoded_header` - The base64url-encoded string from the HTTP header
153    ///
154    /// # Returns
155    /// * `Result<Self, String>` - Decoded response or detailed error message
156    ///
157    /// # Example
158    /// ```
159    /// use ironshield_types::IronShieldToken;
160    /// // Create a response and encode it
161    /// let original = IronShieldToken::new([0xAB; 64], 12345, [0x12; 32], [0x34; 64]);
162    /// let header_value = original.to_base64url_header();
163    /// // Decode it back
164    /// let decoded = IronShieldToken::from_base64url_header(&header_value).unwrap();
165    /// assert_eq!(original.challenge_signature, decoded.challenge_signature);
166    /// ```
167    pub fn from_base64url_header(encoded_header: &str) -> Result<Self, String> {
168        // Decode using the existing serde_utils function.
169        let concat_str: String = crate::serde_utils::concat_struct_base64url_decode(encoded_header.to_string())?;
170
171        // Parse using the existing concat_struct format.
172        Self::from_concat_struct(&concat_str)
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_from_concat_struct_edge_cases() {
182        // Test with a valid minimum length hex (32 bytes = 64 hex chars for public_key, 64 bytes = 128 hex chars for signatures).
183        // Building strings programmatically.
184        let valid_32_byte_hex = "0".repeat(64);  // 32 bytes = 64 hex chars
185        let valid_64_byte_hex = "0".repeat(128); // 64 bytes = 128 hex chars
186        assert_eq!(valid_32_byte_hex.len(), 64, "32-byte hex string should be exactly 64 characters");
187        assert_eq!(valid_64_byte_hex.len(), 128, "64-byte hex string should be exactly 128 characters");
188
189        let input = format!("{}|1000000|{}|{}",
190                            valid_64_byte_hex, valid_32_byte_hex, valid_64_byte_hex);
191        let result = IronShieldToken::from_concat_struct(&input);
192
193        if result.is_err() {
194            panic!("Expected success but got error: {}", result.unwrap_err());
195        }
196
197        let parsed = result.unwrap();
198        assert_eq!(parsed.challenge_signature, [0u8; 64]);
199        assert_eq!(parsed.valid_for, 1000000);
200        assert_eq!(parsed.public_key, [0u8; 32]);
201        assert_eq!(parsed.auth_signature, [0u8; 64]);
202
203        // Test with all F's hex.
204        let all_f_32_hex = "f".repeat(64);   // 32 bytes of 0xFF
205        let all_f_64_hex = "f".repeat(128);  // 64 bytes of 0xFF
206        assert_eq!(all_f_32_hex.len(), 64, "All F's 32-byte hex string should be exactly 64 characters");
207        assert_eq!(all_f_64_hex.len(), 128, "All F's 64-byte hex string should be exactly 128 characters");
208
209        let input = format!("{}|9999999|{}|{}",
210                            all_f_64_hex, all_f_32_hex, all_f_64_hex);
211        let result = IronShieldToken::from_concat_struct(&input);
212
213        if result.is_err() {
214            panic!("Expected success but got error: {}", result.unwrap_err());
215        }
216
217        let parsed = result.unwrap();
218        assert_eq!(parsed.challenge_signature, [0xffu8; 64]);
219        assert_eq!(parsed.valid_for, 9999999);
220        assert_eq!(parsed.public_key, [0xffu8; 32]);
221        assert_eq!(parsed.auth_signature, [0xffu8; 64]);
222    }
223
224    #[test]
225    fn test_concat_struct_roundtrip() {
226        // Create a token with known values.
227        let original_token = IronShieldToken::new(
228            [0xAB; 64],
229            1700000000000,
230            [0xCD; 32],
231            [0xEF; 64],
232        );
233
234        // Convert to concat string and back.
235        let concat_str = original_token.concat_struct();
236        let parsed_token = IronShieldToken::from_concat_struct(&concat_str).unwrap();
237
238        // Verify all fields are preserved.
239        assert_eq!(original_token.challenge_signature, parsed_token.challenge_signature);
240        assert_eq!(original_token.valid_for, parsed_token.valid_for);
241        assert_eq!(original_token.public_key, parsed_token.public_key);
242        assert_eq!(original_token.auth_signature, parsed_token.auth_signature);
243    }
244
245    #[test]
246    fn test_empty_string_parsing() {
247        let result = IronShieldToken::from_concat_struct("");
248        assert!(result.is_err());
249        assert!(result.unwrap_err().contains("Expected 4 parts, got 1"));
250    }
251
252
253    #[test]
254    fn test_from_concat_struct_error_cases() {
255        // Test with the wrong number of parts.
256        let result = IronShieldToken::from_concat_struct("only|two|parts");
257        assert!(result.is_err());
258        assert!(result.unwrap_err().contains("Expected 4 parts, got 3"));
259
260        let result = IronShieldToken::from_concat_struct("too|many|parts|here|extra");
261        assert!(result.is_err());
262        assert!(result.unwrap_err().contains("Expected 4 parts, got 5"));
263
264        // Test with invalid hex for challenge_signature.
265        let valid_32_hex = "0".repeat(64);
266        let valid_64_hex = "0".repeat(128);
267        let invalid_hex = "invalid_hex_string";
268
269        let input = format!("{}|1000000|{}|{}", invalid_hex, valid_32_hex, valid_64_hex);
270        let result = IronShieldToken::from_concat_struct(&input);
271        assert!(result.is_err());
272        assert!(result.unwrap_err().contains("Failed to decode challenge_signature hex string"));
273
274        // Test with invalid hex for public_key.
275        let input = format!("{}|1000000|{}|{}", valid_64_hex, invalid_hex, valid_64_hex);
276        let result = IronShieldToken::from_concat_struct(&input);
277        assert!(result.is_err());
278        assert!(result.unwrap_err().contains("Failed to decode public_key hex string"));
279
280        // Test with invalid hex for authentication_signature.
281        let input = format!("{}|1000000|{}|{}", valid_64_hex, valid_32_hex, invalid_hex);
282        let result = IronShieldToken::from_concat_struct(&input);
283        assert!(result.is_err());
284        assert!(result.unwrap_err().contains("Failed to decode authentication_signature hex string"));
285
286        // Test with an invalid timestamp.
287        let input = format!("{}|not_a_number|{}|{}", valid_64_hex, valid_32_hex, valid_64_hex);
288        let result = IronShieldToken::from_concat_struct(&input);
289        assert!(result.is_err());
290        assert!(result.unwrap_err().contains("Failed to parse valid_for as i64"));
291
292        // Test with wrong length hex strings.
293        let short_hex = "0".repeat(32); // Too short for a 64-byte signature.
294        let input = format!("{}|1000000|{}|{}", short_hex, valid_32_hex, valid_64_hex);
295        let result = IronShieldToken::from_concat_struct(&input);
296        assert!(result.is_err());
297        assert!(result.unwrap_err().contains("Challenge signature must be exactly 64 bytes"));
298
299        let short_32_hex = "0".repeat(32); // Too short for a 32-byte public key.
300        let input = format!("{}|1000000|{}|{}", valid_64_hex, short_32_hex, valid_64_hex);
301        let result = IronShieldToken::from_concat_struct(&input);
302        assert!(result.is_err());
303        assert!(result.unwrap_err().contains("Public key must be exactly 32 bytes"));
304    }
305}