ironshield_types/
token.rs

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