rusty_common/auth/exchanges/
coinbase.rs

1use crate::auth::pem::validate_pem_format;
2use crate::collections::FxHashMap;
3use crate::{CommonError, Result, SmartString};
4use hmac::{Hmac, Mac};
5use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9type HmacSha256 = Hmac<Sha256>;
10
11/// Coinbase API key type - Advanced Trade uses ECDSA
12#[derive(Debug, Clone)]
13pub enum CoinbaseKeyType {
14    /// Legacy HMAC-SHA256 (deprecated in Advanced Trade)
15    Hmac(SmartString),
16    /// ECDSA P-256 for Advanced Trade API
17    Ecdsa(SmartString),
18}
19
20/// Coinbase header key constants for type safety and performance
21pub mod header_keys {
22    use crate::SmartString;
23
24    /// The header key for the API key.
25    pub const CB_ACCESS_KEY: &str = "CB-ACCESS-KEY";
26    /// The header key for the signature.
27    pub const CB_ACCESS_SIGN: &str = "CB-ACCESS-SIGN";
28    /// The header key for the timestamp.
29    pub const CB_ACCESS_TIMESTAMP: &str = "CB-ACCESS-TIMESTAMP";
30    /// The header key for authorization.
31    pub const AUTHORIZATION: &str = "Authorization";
32
33    /// Pre-allocated SmartString constants for zero-allocation header creation
34    /// Returns the `CB-ACCESS-KEY` header key as a `SmartString`.
35    #[must_use]
36    pub fn cb_access_key() -> SmartString {
37        CB_ACCESS_KEY.into()
38    }
39    /// Returns the `CB-ACCESS-SIGN` header key as a `SmartString`.
40    #[must_use]
41    pub fn cb_access_sign() -> SmartString {
42        CB_ACCESS_SIGN.into()
43    }
44    /// Returns the `CB-ACCESS-TIMESTAMP` header key as a `SmartString`.
45    #[must_use]
46    pub fn cb_access_timestamp() -> SmartString {
47        CB_ACCESS_TIMESTAMP.into()
48    }
49    /// Returns the `Authorization` header key as a `SmartString`.
50    #[must_use]
51    pub fn authorization() -> SmartString {
52        AUTHORIZATION.into()
53    }
54}
55
56/// WebSocket subscription message for Coinbase
57#[derive(Debug, Clone, Serialize)]
58pub struct CoinbaseWsSubscription {
59    /// The type of the message, e.g., `subscribe`.
60    #[serde(rename = "type")]
61    pub message_type: SmartString,
62    /// The channels to subscribe to.
63    pub channels: Vec<SmartString>,
64    /// The JWT for authentication.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub jwt: Option<SmartString>,
67    /// The API key for authentication.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub api_key: Option<SmartString>,
70    /// The timestamp for the request.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub timestamp: Option<SmartString>,
73    /// The signature for the request.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub signature: Option<SmartString>,
76}
77
78/// Coinbase authentication implementation for Advanced Trade API
79#[derive(Debug, Clone)]
80pub struct CoinbaseAuth {
81    api_key: SmartString,
82    key_type: CoinbaseKeyType,
83}
84
85/// JWT payload for Coinbase Cloud authentication
86#[derive(Debug, Serialize, Deserialize)]
87struct CoinbaseJwtPayload {
88    sub: SmartString,      // API Key ID
89    iss: SmartString,      // Issuer, typically "coinbase-cloud"
90    nbf: u64,              // Not before timestamp
91    exp: u64,              // Expiration timestamp
92    aud: Vec<SmartString>, // Audience
93}
94
95impl CoinbaseAuth {
96    /// Create new Coinbase auth with HMAC key (legacy)
97    #[must_use]
98    pub const fn new_hmac(api_key: SmartString, secret_key: SmartString) -> Self {
99        Self {
100            api_key,
101            key_type: CoinbaseKeyType::Hmac(secret_key),
102        }
103    }
104
105    /// Create new Coinbase auth with ECDSA key (Advanced Trade)
106    /// private_key should be a PEM-encoded ECDSA P-256 private key
107    pub fn new_ecdsa(api_key: SmartString, private_key: SmartString) -> Result<Self> {
108        // Validate PEM format using proper parser
109        validate_pem_format(&private_key)?;
110
111        Ok(Self {
112            api_key,
113            key_type: CoinbaseKeyType::Ecdsa(private_key),
114        })
115    }
116
117    /// Get current timestamp in seconds
118    fn get_timestamp_seconds() -> u64 {
119        // Use milliseconds and convert to seconds for better precision
120        crate::time::get_timestamp_ms() / 1000
121    }
122
123    /// Generate timestamp in nanoseconds for high-precision timing
124    #[must_use]
125    pub fn generate_timestamp_nanos() -> u128 {
126        // Use the improved time utility that handles clock adjustments properly
127        crate::time::get_timestamp_ns_result().unwrap_or_else(|_| {
128            std::time::SystemTime::now()
129                .duration_since(std::time::UNIX_EPOCH)
130                .unwrap_or_default()
131                .as_nanos() as u64
132        }) as u128
133    }
134
135    /// Generate HMAC-SHA256 signature (legacy method)
136    fn generate_hmac_signature(&self, secret: &str, payload: &str) -> Result<SmartString> {
137        let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
138            .map_err(|e| CommonError::Auth(format!("HMAC error: {e}").into()))?;
139        mac.update(payload.as_bytes());
140        let result = mac.finalize();
141        Ok(hex::encode(result.into_bytes()).into())
142    }
143
144    /// Generate ECDSA signature (Advanced Trade method)
145    fn generate_ecdsa_signature(
146        &self,
147        private_key_pem: &str,
148        _payload: &str,
149    ) -> Result<SmartString> {
150        // Create JWT for Coinbase Cloud API
151        let now = Self::get_timestamp_seconds();
152        let jwt_payload = CoinbaseJwtPayload {
153            sub: self.api_key.clone(),
154            iss: "coinbase-cloud".into(),
155            nbf: now,
156            exp: now + 120, // JWT expires in 2 minutes
157            aud: vec!["retail_rest_api_proxy".into()],
158        };
159
160        let header = Header {
161            alg: Algorithm::ES256, // ECDSA using P-256 curve and SHA-256 hash
162            ..Default::default()
163        };
164
165        let encoding_key = EncodingKey::from_ec_pem(private_key_pem.as_bytes())
166            .map_err(|e| CommonError::Auth(format!("Invalid ECDSA private key: {e}").into()))?;
167
168        let jwt = encode(&header, &jwt_payload, &encoding_key)
169            .map_err(|e| CommonError::Auth(format!("JWT encoding error: {e}").into()))?;
170
171        Ok(jwt.into())
172    }
173
174    /// Generate signature for REST API request
175    fn generate_signature(&self, method: &str, path: &str, body: &str) -> Result<SmartString> {
176        let timestamp = Self::get_timestamp_seconds();
177        let payload = format!("{}{}{}{}", timestamp, method.to_uppercase(), path, body);
178
179        match &self.key_type {
180            CoinbaseKeyType::Hmac(secret) => self.generate_hmac_signature(secret, &payload),
181            CoinbaseKeyType::Ecdsa(private_key) => {
182                self.generate_ecdsa_signature(private_key, &payload)
183            }
184        }
185    }
186
187    /// Generate REST API authentication headers (optimized for performance)
188    /// Returns headers directly as HashMap to avoid conversion overhead in hot paths
189    pub fn generate_headers(
190        &self,
191        method: &str,
192        path: &str,
193        body: Option<&str>,
194    ) -> Result<FxHashMap<SmartString, SmartString>> {
195        // Pre-allocate HashMap with exact capacity for optimal performance
196        let _capacity = match &self.key_type {
197            CoinbaseKeyType::Hmac(_) => 3, // CB-ACCESS-KEY, CB-ACCESS-SIGN, CB-ACCESS-TIMESTAMP
198            CoinbaseKeyType::Ecdsa(_) => 2, // Authorization, CB-ACCESS-KEY
199        };
200        let mut headers = FxHashMap::default();
201        let timestamp = Self::get_timestamp_seconds();
202        let body_str = body.unwrap_or("");
203
204        match &self.key_type {
205            CoinbaseKeyType::Hmac(secret) => {
206                // Legacy authentication
207                let payload = format!("{}{}{}{}", timestamp, method.to_uppercase(), path, body_str);
208                let signature = self.generate_hmac_signature(secret, &payload)?;
209
210                headers.insert(header_keys::cb_access_key(), self.api_key.clone());
211                headers.insert(header_keys::cb_access_sign(), signature);
212                headers.insert(
213                    header_keys::cb_access_timestamp(),
214                    timestamp.to_string().into(),
215                );
216            }
217            CoinbaseKeyType::Ecdsa(private_key) => {
218                // Advanced Trade authentication
219                let jwt = self.generate_ecdsa_signature(private_key, "")?;
220
221                headers.insert(header_keys::authorization(), format!("Bearer {jwt}").into());
222                headers.insert(header_keys::cb_access_key(), self.api_key.clone());
223            }
224        }
225
226        Ok(headers)
227    }
228
229    /// Generate WebSocket subscription message
230    pub fn generate_ws_subscription(&self, channels: &[&str]) -> Result<CoinbaseWsSubscription> {
231        let channels: Vec<SmartString> = channels.iter().map(|&c| c.into()).collect();
232
233        match &self.key_type {
234            CoinbaseKeyType::Ecdsa(private_key) => {
235                // For WebSocket authentication, generate a JWT
236                let jwt = self.generate_ecdsa_signature(private_key, "")?;
237
238                Ok(CoinbaseWsSubscription {
239                    message_type: "subscribe".into(),
240                    channels,
241                    jwt: Some(jwt),
242                    api_key: None,
243                    timestamp: None,
244                    signature: None,
245                })
246            }
247            CoinbaseKeyType::Hmac(_secret) => {
248                // Legacy WebSocket auth uses API key + signature method
249                let timestamp = Self::get_timestamp_seconds();
250                let _payload = format!("{timestamp}user");
251                let signature = self.generate_signature("GET", "/users/self/verify", "")?;
252
253                Ok(CoinbaseWsSubscription {
254                    message_type: "subscribe".into(),
255                    channels,
256                    jwt: None,
257                    api_key: Some(self.api_key.clone()),
258                    timestamp: Some(timestamp.to_string().into()),
259                    signature: Some(signature),
260                })
261            }
262        }
263    }
264
265    /// Get JWT token for ECDSA authentication (Advanced Trade)
266    pub fn generate_jwt(&self) -> Result<SmartString> {
267        match &self.key_type {
268            CoinbaseKeyType::Ecdsa(private_key) => self.generate_ecdsa_signature(private_key, ""),
269            CoinbaseKeyType::Hmac(_) => Err(CommonError::Auth(SmartString::from(
270                "JWT generation is only available for ECDSA authentication",
271            ))),
272        }
273    }
274
275    /// Generate HMAC signature for legacy authentication
276    pub fn generate_signature_for_request(
277        &self,
278        method: &str,
279        path: &str,
280        body: &str,
281    ) -> Result<SmartString> {
282        match &self.key_type {
283            CoinbaseKeyType::Hmac(secret) => {
284                let timestamp = Self::get_timestamp_seconds();
285                let payload = format!("{}{}{}{}", timestamp, method.to_uppercase(), path, body);
286                self.generate_hmac_signature(secret, &payload)
287            }
288            CoinbaseKeyType::Ecdsa(_) => Err(CommonError::Auth(SmartString::from(
289                "HMAC signature generation is only available for HMAC authentication",
290            ))),
291        }
292    }
293
294    /// Optimized parameter string building using the pure URL encoding function
295    /// Uses the auth module's URL encoding for consistency and performance
296    pub fn build_param_string_optimized(params: &[(&str, &str)]) -> Result<SmartString> {
297        if params.is_empty() {
298            return Ok(SmartString::new());
299        }
300
301        let mut result = SmartString::new();
302
303        for (i, (key, value)) in params.iter().enumerate() {
304            if i > 0 {
305                result.push('&');
306            }
307            result.push_str(key);
308            result.push('=');
309
310            // URL encode only the value, not the key or structure characters
311            let mut encoded_value = SmartString::new();
312            crate::auth::exchanges::url_encode_params(value, &mut encoded_value)?;
313            result.push_str(&encoded_value);
314        }
315
316        Ok(result)
317    }
318
319    /// Fast parameter count for pre-allocation decisions
320    #[inline]
321    #[must_use]
322    pub fn param_count(params: Option<&[(&str, &str)]>) -> usize {
323        params.map_or(0, |p| p.len())
324    }
325
326    /// Create a SHA256 hash of query parameters (for GET requests with query parameters)
327    #[allow(dead_code)]
328    fn create_query_hash(query_string: &str) -> SmartString {
329        let mut hasher = Sha256::new();
330        hasher.update(query_string.as_bytes());
331        hex::encode(hasher.finalize()).into()
332    }
333
334    /// Generate FIX protocol authentication (username and password)
335    /// For Coinbase FIX, username is the API key and password is a signed message
336    pub fn generate_fix_auth(&self) -> Result<(SmartString, SmartString)> {
337        let username = self.api_key.clone();
338
339        match &self.key_type {
340            CoinbaseKeyType::Ecdsa(private_key) => {
341                // For FIX with ECDSA, we use JWT as the password
342                let jwt = self.generate_ecdsa_signature(private_key, "")?;
343                Ok((username, jwt))
344            }
345            CoinbaseKeyType::Hmac(secret) => {
346                // For FIX with HMAC, create a signed timestamp
347                let timestamp = Self::get_timestamp_seconds();
348                let payload = format!("{timestamp}");
349                let signature = self.generate_hmac_signature(secret, &payload)?;
350                let password = format!("{timestamp}:{signature}").into();
351                Ok((username, password))
352            }
353        }
354    }
355
356    /// Generate HMAC signature for FIX logon message
357    /// Used by FIX client for creating signed authentication messages
358    pub fn sign_hmac(&self, data: &[u8]) -> Result<SmartString> {
359        match &self.key_type {
360            CoinbaseKeyType::Hmac(secret) => {
361                let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
362                    .map_err(|e| CommonError::Auth(format!("HMAC error: {e}").into()))?;
363                mac.update(data);
364                let result = mac.finalize();
365                use base64::{Engine as _, engine::general_purpose};
366                Ok(general_purpose::STANDARD.encode(result.into_bytes()).into())
367            }
368            CoinbaseKeyType::Ecdsa(_) => Err(CommonError::Auth(SmartString::from(
369                "HMAC signing is only available for HMAC authentication",
370            ))),
371        }
372    }
373
374    /// Get passphrase for authentication (used by FIX protocol)
375    /// Returns the passphrase field needed for Coinbase FIX authentication
376    pub fn passphrase(&self) -> SmartString {
377        // For Coinbase, passphrase is typically the same as the API key
378        // This can be overridden if a different passphrase is used
379        self.api_key.clone()
380    }
381}
382
383impl CoinbaseAuth {
384    /// Get API key
385    #[must_use]
386    pub fn api_key(&self) -> &str {
387        &self.api_key
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_build_param_string_optimized_empty() {
397        let params: &[(&str, &str)] = &[];
398        let result = CoinbaseAuth::build_param_string_optimized(params);
399        assert!(result.is_ok());
400        assert_eq!(result.unwrap(), SmartString::from(""));
401    }
402
403    #[test]
404    fn test_build_param_string_optimized_single_param() {
405        let params = &[("key1", "value1")];
406        let result = CoinbaseAuth::build_param_string_optimized(params);
407        assert!(result.is_ok());
408        assert_eq!(result.unwrap(), SmartString::from("key1=value1"));
409    }
410
411    #[test]
412    fn test_build_param_string_optimized_multiple_params() {
413        let params = &[("symbol", "BTC-USD"), ("side", "buy"), ("type", "limit")];
414        let result = CoinbaseAuth::build_param_string_optimized(params);
415        assert!(result.is_ok());
416        assert_eq!(
417            result.unwrap(),
418            SmartString::from("symbol=BTC-USD&side=buy&type=limit")
419        );
420    }
421
422    #[test]
423    fn test_build_param_string_optimized_url_encoding() {
424        let params = &[("key", "value with spaces"), ("special", "chars!@#$%")];
425        let result = CoinbaseAuth::build_param_string_optimized(params);
426        assert!(result.is_ok());
427        let encoded = result.unwrap();
428        assert!(encoded.contains("value%20with%20spaces"));
429        assert!(encoded.contains("chars%21%40%23%24%25"));
430    }
431
432    #[test]
433    fn test_param_count() {
434        assert_eq!(CoinbaseAuth::param_count(None), 0);
435        assert_eq!(CoinbaseAuth::param_count(Some(&[])), 0);
436        assert_eq!(CoinbaseAuth::param_count(Some(&[("key", "value")])), 1);
437        assert_eq!(
438            CoinbaseAuth::param_count(Some(&[("key1", "value1"), ("key2", "value2")])),
439            2
440        );
441    }
442
443    #[test]
444    fn test_generate_timestamp_nanos() {
445        let timestamp1 = CoinbaseAuth::generate_timestamp_nanos();
446        std::thread::sleep(std::time::Duration::from_nanos(1));
447        let timestamp2 = CoinbaseAuth::generate_timestamp_nanos();
448        assert!(timestamp2 > timestamp1);
449    }
450
451    #[test]
452    fn test_hmac_auth_creation() {
453        let auth = CoinbaseAuth::new_hmac("test_api_key".into(), "test_secret".into());
454        assert_eq!(auth.api_key(), "test_api_key");
455    }
456
457    #[test]
458    fn test_ecdsa_auth_creation() {
459        // Test with a mock PEM format (not a real key)
460        let mock_pem = "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_DATA\n-----END PRIVATE KEY-----";
461        let auth = CoinbaseAuth::new_ecdsa("test_api_key".into(), mock_pem.into());
462        assert!(auth.is_ok());
463        let auth = auth.unwrap();
464        assert_eq!(auth.api_key(), "test_api_key");
465    }
466
467    #[test]
468    fn test_ecdsa_invalid_key_format() {
469        // Test with invalid key format
470        let auth = CoinbaseAuth::new_ecdsa("test_api_key".into(), "invalid_key_format".into());
471        assert!(auth.is_err());
472    }
473
474    #[test]
475    fn test_query_hash_generation() {
476        let query = "symbol=BTC-USD&side=buy";
477        let hash = CoinbaseAuth::create_query_hash(query);
478        assert!(!hash.is_empty());
479        assert_eq!(hash.len(), 64); // SHA256 hex string is 64 characters
480    }
481
482    #[test]
483    fn test_hmac_headers_generation() {
484        let auth = CoinbaseAuth::new_hmac("test_api_key".into(), "test_secret".into());
485
486        let headers = auth.generate_headers("GET", "/accounts", None);
487        assert!(headers.is_ok());
488
489        let headers = headers.unwrap();
490        assert_eq!(
491            headers.get(&header_keys::cb_access_key()).unwrap(),
492            "test_api_key"
493        );
494        assert!(headers.contains_key(&header_keys::cb_access_sign()));
495        assert!(headers.contains_key(&header_keys::cb_access_timestamp()));
496        assert!(
497            !headers
498                .get(&header_keys::cb_access_sign())
499                .unwrap()
500                .is_empty()
501        );
502        assert!(
503            !headers
504                .get(&header_keys::cb_access_timestamp())
505                .unwrap()
506                .is_empty()
507        );
508    }
509
510    #[test]
511    fn test_usage_example() {
512        // Example 1: HMAC Authentication (Legacy)
513        let hmac_auth = CoinbaseAuth::new_hmac("your_api_key".into(), "your_secret_key".into());
514
515        let headers = hmac_auth.generate_headers("GET", "/accounts", None);
516        assert!(headers.is_ok());
517
518        // Example 2: ECDSA Authentication (Advanced Trade - Recommended)
519        // Note: This test only validates creation, not actual JWT generation
520        // as we don't have a real ECDSA key for testing
521        let mock_pem = "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_DATA\n-----END PRIVATE KEY-----";
522        let ecdsa_auth = CoinbaseAuth::new_ecdsa("your_api_key".into(), mock_pem.into()).unwrap();
523
524        // Verify the auth was created successfully
525        assert_eq!(ecdsa_auth.api_key(), "your_api_key");
526
527        // Test HMAC WebSocket subscription generation (this will work with mock credentials)
528        let hmac_ws_subscription = hmac_auth.generate_ws_subscription(&["user", "heartbeat"]);
529        assert!(hmac_ws_subscription.is_ok());
530        let hmac_subscription = hmac_ws_subscription.unwrap();
531        assert_eq!(hmac_subscription.message_type, "subscribe");
532        assert_eq!(hmac_subscription.channels.len(), 2);
533        assert!(hmac_subscription.jwt.is_none());
534        assert!(hmac_subscription.api_key.is_some());
535
536        // Note: WebSocket subscription generation would fail with mock ECDSA keys
537        // as JWT generation requires a valid ECDSA private key
538        // In real usage, this would work with actual ECDSA private keys
539    }
540}