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#[derive(Debug, Clone)]
13pub enum CoinbaseKeyType {
14 Hmac(SmartString),
16 Ecdsa(SmartString),
18}
19
20pub mod header_keys {
22 use crate::SmartString;
23
24 pub const CB_ACCESS_KEY: &str = "CB-ACCESS-KEY";
26 pub const CB_ACCESS_SIGN: &str = "CB-ACCESS-SIGN";
28 pub const CB_ACCESS_TIMESTAMP: &str = "CB-ACCESS-TIMESTAMP";
30 pub const AUTHORIZATION: &str = "Authorization";
32
33 #[must_use]
36 pub fn cb_access_key() -> SmartString {
37 CB_ACCESS_KEY.into()
38 }
39 #[must_use]
41 pub fn cb_access_sign() -> SmartString {
42 CB_ACCESS_SIGN.into()
43 }
44 #[must_use]
46 pub fn cb_access_timestamp() -> SmartString {
47 CB_ACCESS_TIMESTAMP.into()
48 }
49 #[must_use]
51 pub fn authorization() -> SmartString {
52 AUTHORIZATION.into()
53 }
54}
55
56#[derive(Debug, Clone, Serialize)]
58pub struct CoinbaseWsSubscription {
59 #[serde(rename = "type")]
61 pub message_type: SmartString,
62 pub channels: Vec<SmartString>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub jwt: Option<SmartString>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub api_key: Option<SmartString>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub timestamp: Option<SmartString>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub signature: Option<SmartString>,
76}
77
78#[derive(Debug, Clone)]
80pub struct CoinbaseAuth {
81 api_key: SmartString,
82 key_type: CoinbaseKeyType,
83}
84
85#[derive(Debug, Serialize, Deserialize)]
87struct CoinbaseJwtPayload {
88 sub: SmartString, iss: SmartString, nbf: u64, exp: u64, aud: Vec<SmartString>, }
94
95impl CoinbaseAuth {
96 #[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 pub fn new_ecdsa(api_key: SmartString, private_key: SmartString) -> Result<Self> {
108 validate_pem_format(&private_key)?;
110
111 Ok(Self {
112 api_key,
113 key_type: CoinbaseKeyType::Ecdsa(private_key),
114 })
115 }
116
117 fn get_timestamp_seconds() -> u64 {
119 crate::time::get_timestamp_ms() / 1000
121 }
122
123 #[must_use]
125 pub fn generate_timestamp_nanos() -> u128 {
126 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 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 fn generate_ecdsa_signature(
146 &self,
147 private_key_pem: &str,
148 _payload: &str,
149 ) -> Result<SmartString> {
150 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, aud: vec!["retail_rest_api_proxy".into()],
158 };
159
160 let header = Header {
161 alg: Algorithm::ES256, ..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 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 pub fn generate_headers(
190 &self,
191 method: &str,
192 path: &str,
193 body: Option<&str>,
194 ) -> Result<FxHashMap<SmartString, SmartString>> {
195 let _capacity = match &self.key_type {
197 CoinbaseKeyType::Hmac(_) => 3, CoinbaseKeyType::Ecdsa(_) => 2, };
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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 let jwt = self.generate_ecdsa_signature(private_key, "")?;
343 Ok((username, jwt))
344 }
345 CoinbaseKeyType::Hmac(secret) => {
346 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 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 pub fn passphrase(&self) -> SmartString {
377 self.api_key.clone()
380 }
381}
382
383impl CoinbaseAuth {
384 #[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 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 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); }
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 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 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 assert_eq!(ecdsa_auth.api_key(), "your_api_key");
526
527 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 }
540}