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