rusty_common/auth/
ed25519.rs

1//! Ed25519 signature utilities for exchange authentication
2//!
3//! Provides a generic Ed25519 authentication implementation that can be used
4//! by any exchange requiring Ed25519 signatures. Built on top of ed25519-dalek
5//! for cryptographic operations.
6
7use crate::collections::FxHashMap;
8use crate::{CommonError, Result, SmartString};
9use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
10use hex;
11use std::fmt::Debug;
12
13/// Ed25519 authentication implementation for exchanges
14#[derive(Debug, Clone)]
15pub struct Ed25519Auth {
16    signing_key: SigningKey,
17    verifying_key: VerifyingKey,
18    api_key: SmartString,
19}
20
21impl Ed25519Auth {
22    /// Create new Ed25519 authenticator with existing signing key
23    #[must_use]
24    pub fn new(signing_key: SigningKey, api_key: SmartString) -> Self {
25        let verifying_key = signing_key.verifying_key();
26        Self {
27            signing_key,
28            verifying_key,
29            api_key,
30        }
31    }
32
33    /// Create Ed25519 authenticator from hex-encoded secret key
34    ///
35    /// # Arguments
36    /// * `secret_hex` - 64-character hex string representing the 32-byte secret key
37    /// * `api_key` - API key identifier for the exchange
38    ///
39    /// # Errors
40    /// Returns error if the hex string is invalid or not 64 characters
41    pub fn from_secret_key_hex(secret_hex: &str, api_key: SmartString) -> Result<Self> {
42        // Validate hex string length (32 bytes = 64 hex characters)
43        if secret_hex.len() != 64 {
44            return Err(CommonError::Auth(
45                format!(
46                    "Invalid secret key length: expected 64 hex characters, got {}",
47                    secret_hex.len()
48                )
49                .into(),
50            ));
51        }
52
53        // Decode hex string to bytes
54        let secret_bytes = hex::decode(secret_hex)
55            .map_err(|e| CommonError::Auth(format!("Invalid hex in secret key: {e}").into()))?;
56
57        // Convert to array (ed25519-dalek requires exactly 32 bytes)
58        let secret_array: [u8; 32] = secret_bytes
59            .try_into()
60            .map_err(|_| CommonError::Auth("Secret key must be exactly 32 bytes".into()))?;
61
62        // Create signing key from secret bytes
63        let signing_key = SigningKey::from_bytes(&secret_array);
64        let verifying_key = signing_key.verifying_key();
65
66        Ok(Self {
67            signing_key,
68            verifying_key,
69            api_key,
70        })
71    }
72
73    /// Sign query parameters using Ed25519
74    ///
75    /// Creates a query string from parameters and signs it using Ed25519.
76    /// Parameters are URL-encoded and joined with '&' separator.
77    ///
78    /// # Arguments
79    /// * `params` - Key-value pairs to sign
80    ///
81    /// # Returns
82    /// Hex-encoded Ed25519 signature
83    pub fn sign_params(&self, params: &[(&str, &str)]) -> Result<SmartString> {
84        // Build query string from parameters
85        let query_string = build_query_string(params)?;
86
87        // Use existing signing key
88
89        // Sign the query string
90        let signature = self.signing_key.sign(query_string.as_bytes());
91
92        // Return hex-encoded signature
93        Ok(hex::encode(signature.to_bytes()).into())
94    }
95
96    /// Sign a complete HTTP request
97    ///
98    /// Creates authentication headers for an HTTP request including the signature.
99    /// The signature is computed over the request parameters.
100    ///
101    /// # Arguments
102    /// * `method` - HTTP method (GET, POST, etc.)
103    /// * `path` - Request path
104    /// * `body` - Optional request body (currently unused but reserved for future use)
105    ///
106    /// # Returns
107    /// HashMap containing authentication headers
108    pub fn sign_request(
109        &self,
110        _method: &str,
111        _path: &str,
112        _body: Option<&str>,
113    ) -> Result<FxHashMap<SmartString, SmartString>> {
114        let mut headers = FxHashMap::default();
115
116        // Add API key header
117        headers.insert("X-MBX-APIKEY".into(), self.api_key.clone());
118
119        // Note: Actual signature generation would require parameters
120        // This is a placeholder for the generic interface
121        // Exchange-specific implementations should override this method
122
123        Ok(headers)
124    }
125
126    /// Get the API key
127    #[must_use]
128    pub fn api_key(&self) -> &str {
129        &self.api_key
130    }
131
132    /// Get the public key as hex string
133    #[must_use]
134    pub fn public_key_hex(&self) -> SmartString {
135        hex::encode(self.verifying_key.to_bytes()).into()
136    }
137
138    /// Get the signing key reference (for advanced use cases)
139    #[must_use]
140    pub const fn signing_key(&self) -> &SigningKey {
141        &self.signing_key
142    }
143
144    /// Get the verifying key reference (for advanced use cases)
145    #[must_use]
146    pub const fn verifying_key(&self) -> &VerifyingKey {
147        &self.verifying_key
148    }
149}
150
151/// Generate Ed25519 signature for parameters (standalone function)
152///
153/// This is a convenience function that creates a temporary Ed25519Auth
154/// instance and signs the parameters.
155///
156/// # Arguments
157/// * `secret_key_hex` - Hex-encoded secret key
158/// * `params` - Parameters to sign
159///
160/// # Returns
161/// Hex-encoded signature
162pub fn generate_ed25519_signature(
163    secret_key_hex: &str,
164    params: &[(&str, &str)],
165) -> Result<SmartString> {
166    let auth = Ed25519Auth::from_secret_key_hex(secret_key_hex, "temp".into())?;
167    auth.sign_params(params)
168}
169
170/// Build query string from parameters
171///
172/// Creates a URL-encoded query string from key-value pairs.
173/// Parameters are sorted by key for consistent signature generation.
174///
175/// # Arguments
176/// * `params` - Key-value pairs
177///
178/// # Returns
179/// URL-encoded query string
180fn build_query_string(params: &[(&str, &str)]) -> Result<SmartString> {
181    if params.is_empty() {
182        return Ok(SmartString::new());
183    }
184
185    // Sort parameters by key for consistent ordering
186    let mut sorted_params = params.to_vec();
187    sorted_params.sort_by_key(|&(k, _)| k);
188
189    let mut query_parts = Vec::with_capacity(sorted_params.len());
190
191    for (key, value) in sorted_params {
192        // URL encode both key and value for correctness
193        let encoded_key = urlencoding::encode(key);
194        let encoded_value = urlencoding::encode(value);
195        query_parts.push(format!("{encoded_key}={encoded_value}"));
196    }
197
198    Ok(query_parts.join("&").into())
199}
200
201/// Generate a test Ed25519 signing key with fixed key for deterministic testing
202///
203/// # Returns
204/// Fixed Ed25519 signing key for testing purposes
205#[cfg(test)]
206pub fn generate_test_signing_key() -> SigningKey {
207    // Use a fixed key for deterministic testing
208    let test_key_bytes = [
209        0x4c, 0x5b, 0x2e, 0x7d, 0x3f, 0x8a, 0x9b, 0x1c, 0x6e, 0x5d, 0x4a, 0x3b, 0x2c, 0x1d, 0x0e,
210        0x9f, 0x8a, 0x7b, 0x6c, 0x5d, 0x4e, 0x3f, 0x2a, 0x1b, 0x0c, 0x9d, 0x8e, 0x7f, 0x6a, 0x5b,
211        0x4c, 0x3d,
212    ];
213    SigningKey::from_bytes(&test_key_bytes)
214}
215
216/// Create test Ed25519Auth instance for testing
217#[cfg(test)]
218pub fn create_test_auth() -> Ed25519Auth {
219    let signing_key = generate_test_signing_key();
220    Ed25519Auth::new(signing_key, "test-api-key".into())
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_ed25519_auth_creation_from_hex() {
229        // Valid 64-character hex string (32 bytes)
230        let secret_hex = "4c5b2e7d3f8a9b1c6e5d4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d";
231        let api_key = "test-api-key".into();
232
233        let auth = Ed25519Auth::from_secret_key_hex(secret_hex, api_key);
234        assert!(auth.is_ok());
235
236        let auth = auth.unwrap();
237        assert_eq!(auth.api_key(), "test-api-key");
238        assert_eq!(auth.public_key_hex().len(), 64); // 32 bytes = 64 hex chars
239    }
240
241    #[test]
242    fn test_ed25519_auth_invalid_hex_length() {
243        // Too short
244        let short_hex = "4c5b2e7d3f8a9b1c";
245        let result = Ed25519Auth::from_secret_key_hex(short_hex, "test".into());
246        assert!(result.is_err());
247        assert!(
248            result
249                .unwrap_err()
250                .to_string()
251                .contains("Invalid secret key length")
252        );
253
254        // Too long
255        let long_hex = "4c5b2e7d3f8a9b1c6e5d4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d1234";
256        let result = Ed25519Auth::from_secret_key_hex(long_hex, "test".into());
257        assert!(result.is_err());
258        assert!(
259            result
260                .unwrap_err()
261                .to_string()
262                .contains("Invalid secret key length")
263        );
264    }
265
266    #[test]
267    fn test_ed25519_auth_invalid_hex_chars() {
268        // Contains non-hex characters
269        let invalid_hex = "4c5b2e7d3f8a9b1c6e5d4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4cZZ";
270        let result = Ed25519Auth::from_secret_key_hex(invalid_hex, "test".into());
271        assert!(result.is_err());
272        assert!(result.unwrap_err().to_string().contains("Invalid hex"));
273    }
274
275    #[test]
276    fn test_sign_params_empty() {
277        let auth = create_test_auth();
278        let signature = auth.sign_params(&[]);
279        assert!(signature.is_ok());
280
281        let sig = signature.unwrap();
282        assert_eq!(sig.len(), 128); // Ed25519 signature is 64 bytes = 128 hex chars
283    }
284
285    #[test]
286    fn test_sign_params_single() {
287        let auth = create_test_auth();
288        let params = [("symbol", "BTCUSDT")];
289        let signature = auth.sign_params(&params);
290        assert!(signature.is_ok());
291
292        let sig = signature.unwrap();
293        assert_eq!(sig.len(), 128);
294        assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
295    }
296
297    #[test]
298    fn test_sign_params_multiple() {
299        let auth = create_test_auth();
300        let params = [
301            ("symbol", "BTCUSDT"),
302            ("side", "BUY"),
303            ("type", "LIMIT"),
304            ("quantity", "1"),
305            ("price", "50000"),
306        ];
307        let signature = auth.sign_params(&params);
308        assert!(signature.is_ok());
309
310        let sig = signature.unwrap();
311        assert_eq!(sig.len(), 128);
312        assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
313    }
314
315    #[test]
316    fn test_sign_params_with_special_chars() {
317        let auth = create_test_auth();
318        let params = [
319            ("symbol", "BTC/USDT"),
320            ("message", "Hello World!"),
321            ("special", "[email protected]"),
322        ];
323        let signature = auth.sign_params(&params);
324        assert!(signature.is_ok());
325
326        let sig = signature.unwrap();
327        assert_eq!(sig.len(), 128);
328    }
329
330    #[test]
331    fn test_signature_consistency() {
332        let auth = create_test_auth();
333        let params = [("symbol", "BTCUSDT"), ("price", "50000")];
334
335        let sig1 = auth.sign_params(&params).unwrap();
336        let sig2 = auth.sign_params(&params).unwrap();
337
338        assert_eq!(sig1, sig2, "Signatures should be deterministic");
339    }
340
341    #[test]
342    fn test_signature_sensitivity() {
343        let auth = create_test_auth();
344
345        let params1 = [("symbol", "BTCUSDT")];
346        let params2 = [("symbol", "ETHUSDT")]; // Different value
347
348        let sig1 = auth.sign_params(&params1).unwrap();
349        let sig2 = auth.sign_params(&params2).unwrap();
350
351        assert_ne!(
352            sig1, sig2,
353            "Different parameters should produce different signatures"
354        );
355    }
356
357    #[test]
358    fn test_parameter_ordering() {
359        let auth = create_test_auth();
360
361        // Same parameters in different order
362        let params1 = [("symbol", "BTCUSDT"), ("side", "BUY")];
363        let params2 = [("side", "BUY"), ("symbol", "BTCUSDT")];
364
365        let sig1 = auth.sign_params(&params1).unwrap();
366        let sig2 = auth.sign_params(&params2).unwrap();
367
368        assert_eq!(sig1, sig2, "Parameter order should not affect signature");
369    }
370
371    #[test]
372    fn test_generate_ed25519_signature_function() {
373        let secret_hex = "4c5b2e7d3f8a9b1c6e5d4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d";
374        let params = [("symbol", "BTCUSDT"), ("side", "BUY")];
375
376        let signature = generate_ed25519_signature(secret_hex, &params);
377        assert!(signature.is_ok());
378
379        let sig = signature.unwrap();
380        assert_eq!(sig.len(), 128);
381        assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
382    }
383
384    #[test]
385    fn test_sign_request_headers() {
386        let auth = create_test_auth();
387        let headers = auth.sign_request("GET", "/api/v3/account", None);
388        assert!(headers.is_ok());
389
390        let headers = headers.unwrap();
391        let api_key_header: SmartString = "X-MBX-APIKEY".into();
392        assert!(headers.contains_key(&api_key_header));
393        assert_eq!(headers.get(&api_key_header).unwrap(), "test-api-key");
394    }
395
396    #[test]
397    fn test_public_key_generation() {
398        let secret_hex = "4c5b2e7d3f8a9b1c6e5d4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d";
399        let auth = Ed25519Auth::from_secret_key_hex(secret_hex, "test".into()).unwrap();
400
401        let public_key = auth.public_key_hex();
402        assert_eq!(public_key.len(), 64); // 32 bytes = 64 hex chars
403        assert!(public_key.chars().all(|c| c.is_ascii_hexdigit()));
404    }
405
406    #[test]
407    fn test_build_query_string() {
408        // Empty parameters
409        let result = build_query_string(&[]);
410        assert!(result.is_ok());
411        assert_eq!(result.unwrap(), "");
412
413        // Single parameter
414        let result = build_query_string(&[("key", "value")]);
415        assert!(result.is_ok());
416        assert_eq!(result.unwrap(), "key=value");
417
418        // Multiple parameters (should be sorted by key)
419        let result = build_query_string(&[("zebra", "last"), ("alpha", "first")]);
420        assert!(result.is_ok());
421        assert_eq!(result.unwrap(), "alpha=first&zebra=last");
422
423        // Parameters with special characters
424        let result = build_query_string(&[("symbol", "BTC/USDT"), ("message", "Hello World!")]);
425        assert!(result.is_ok());
426        let query = result.unwrap();
427        assert!(query.contains("BTC%2FUSDT")); // URL encoded
428        assert!(query.contains("Hello%20World%21")); // URL encoded
429    }
430}