nautilus_coinbase/common/
credential.rs1use std::fmt::{Debug, Display};
17
18use aws_lc_rs::{
19 rand as lc_rand,
20 signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair},
21};
22use base64::prelude::*;
23use nautilus_core::env::resolve_env_var_pair;
24use serde_json::json;
25use zeroize::{Zeroize, ZeroizeOnDrop};
26
27use crate::{
28 common::consts::{JWT_EXPIRY_SECS, JWT_ISSUER},
29 http::error::{Error, Result},
30};
31
32#[must_use]
34pub fn credential_env_vars() -> (&'static str, &'static str) {
35 ("COINBASE_API_KEY", "COINBASE_API_SECRET")
36}
37
38fn base64url_encode(data: &[u8]) -> String {
39 BASE64_URL_SAFE_NO_PAD.encode(data)
40}
41
42#[derive(Clone, Zeroize, ZeroizeOnDrop)]
44pub struct CoinbaseCredential {
45 api_key: String,
46 api_secret: String,
47}
48
49impl CoinbaseCredential {
50 pub fn new(api_key: String, api_secret: String) -> Self {
52 Self {
53 api_key,
54 api_secret,
55 }
56 }
57
58 #[must_use]
61 pub fn resolve(api_key: Option<&str>, api_secret: Option<&str>) -> Option<Self> {
62 let (key_var, secret_var) = credential_env_vars();
63 let (key, secret) = resolve_env_var_pair(
64 api_key.filter(|s| !s.trim().is_empty()).map(String::from),
65 api_secret
66 .filter(|s| !s.trim().is_empty())
67 .map(String::from),
68 key_var,
69 secret_var,
70 )?;
71 Some(Self::new(key, secret))
72 }
73
74 pub fn from_env() -> Result<Self> {
80 let (key_var, secret_var) = credential_env_vars();
81 Self::resolve(None, None).ok_or_else(|| {
82 Error::auth(format!(
83 "{key_var} and {secret_var} environment variables are required"
84 ))
85 })
86 }
87
88 pub fn api_key(&self) -> &str {
90 &self.api_key
91 }
92
93 pub fn api_secret(&self) -> &str {
95 &self.api_secret
96 }
97
98 pub fn build_rest_jwt(&self, uri: &str) -> Result<String> {
103 self.build_jwt(Some(uri))
104 }
105
106 pub fn build_ws_jwt(&self) -> Result<String> {
108 self.build_jwt(None)
109 }
110
111 fn build_jwt(&self, uri: Option<&str>) -> Result<String> {
113 let now = std::time::SystemTime::now()
114 .duration_since(std::time::UNIX_EPOCH)
115 .map_err(|e| Error::auth(format!("Failed to get system time: {e}")))?
116 .as_secs();
117
118 let nonce = {
119 let mut buf = [0u8; 16];
120 lc_rand::fill(&mut buf)
121 .map_err(|e| Error::auth(format!("Failed to generate nonce: {e}")))?;
122 nautilus_core::hex::encode(buf)
123 };
124
125 let header = json!({
126 "alg": "ES256",
127 "typ": "JWT",
128 "kid": self.api_key,
129 "nonce": nonce,
130 });
131
132 let mut payload = json!({
133 "sub": self.api_key,
134 "iss": JWT_ISSUER,
135 "nbf": now,
136 "exp": now + JWT_EXPIRY_SECS,
137 });
138
139 if let Some(uri) = uri {
140 payload["uri"] = serde_json::Value::String(uri.to_string());
141 }
142
143 let header_b64 = base64url_encode(header.to_string().as_bytes());
144 let payload_b64 = base64url_encode(payload.to_string().as_bytes());
145 let signing_input = format!("{header_b64}.{payload_b64}");
146
147 let pem_str = self.api_secret.trim().replace("\\n", "\n");
150
151 let pem_obj = pem::parse(&pem_str)
152 .map_err(|e| Error::auth(format!("Failed to parse PEM key: {e}")))?;
153
154 let key_pair = EcdsaKeyPair::from_private_key_der(
157 &ECDSA_P256_SHA256_FIXED_SIGNING,
158 pem_obj.contents(),
159 )
160 .map_err(|e| Error::auth(format!("Failed to load EC private key: {e}")))?;
161
162 let rng = lc_rand::SystemRandom::new();
163 let sig = key_pair
164 .sign(&rng, signing_input.as_bytes())
165 .map_err(|e| Error::auth(format!("Failed to sign JWT: {e}")))?;
166
167 let sig_b64 = base64url_encode(sig.as_ref());
168
169 Ok(format!("{signing_input}.{sig_b64}"))
170 }
171}
172
173impl Debug for CoinbaseCredential {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 f.debug_struct(stringify!(CoinbaseCredential))
176 .field(
177 "api_key",
178 &format!("{}...", &self.api_key[..8.min(self.api_key.len())]),
179 )
180 .field("api_secret", &"***redacted***")
181 .finish()
182 }
183}
184
185impl Display for CoinbaseCredential {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 write!(
188 f,
189 "CoinbaseCredential({}...)",
190 &self.api_key[..8.min(self.api_key.len())]
191 )
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use aws_lc_rs::encoding::AsDer;
198 use rstest::rstest;
199
200 use super::*;
201
202 const TEST_API_KEY: &str = "organizations/test-org/apiKeys/test-key-id";
203
204 fn test_sec1_pem_key() -> String {
206 let rng = lc_rand::SystemRandom::new();
207 let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
208 let key_pair =
209 EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref()).unwrap();
210 let sec1_der = key_pair.private_key().as_der().unwrap();
211 let pem_obj = pem::Pem::new("EC PRIVATE KEY", sec1_der.as_ref().to_vec());
212 pem::encode(&pem_obj)
213 }
214
215 fn test_pkcs8_pem_key() -> String {
217 let rng = lc_rand::SystemRandom::new();
218 let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
219 let pem_obj = pem::Pem::new("PRIVATE KEY", pkcs8.as_ref().to_vec());
220 pem::encode(&pem_obj)
221 }
222
223 #[rstest]
224 fn test_credential_debug_redacts_secret() {
225 let cred = CoinbaseCredential::new(TEST_API_KEY.to_string(), "my_secret_pem".to_string());
226 let debug = format!("{cred:?}");
227 assert!(debug.contains("redacted"));
228 assert!(!debug.contains("my_secret_pem"));
229 }
230
231 #[rstest]
232 fn test_credential_display_truncates_key() {
233 let cred = CoinbaseCredential::new(TEST_API_KEY.to_string(), "my_secret_pem".to_string());
234 let display = format!("{cred}");
235 assert!(display.contains("organiza..."));
236 assert!(!display.contains("my_secret_pem"));
237 }
238
239 #[rstest]
240 fn test_build_rest_jwt() {
241 let pem_key = test_sec1_pem_key();
242 let cred = CoinbaseCredential::new(TEST_API_KEY.to_string(), pem_key);
243 let jwt = cred.build_rest_jwt("GET api.coinbase.com/api/v3/brokerage/accounts");
244 assert!(jwt.is_ok());
245
246 let token = jwt.unwrap();
247 let parts: Vec<&str> = token.split('.').collect();
248 assert_eq!(parts.len(), 3, "JWT must have 3 parts");
249
250 let header_bytes = BASE64_URL_SAFE_NO_PAD.decode(parts[0]).unwrap();
252 let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap();
253 assert_eq!(header["alg"], "ES256");
254 assert_eq!(header["typ"], "JWT");
255 assert_eq!(header["kid"], TEST_API_KEY);
256 assert!(header["nonce"].is_string());
257
258 let payload_bytes = BASE64_URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
260 let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
261 assert_eq!(payload["sub"], TEST_API_KEY);
262 assert_eq!(payload["iss"], "cdp");
263 assert!(payload["nbf"].is_number());
264 assert!(payload["exp"].is_number());
265 assert!(payload["uri"].is_string());
266 }
267
268 #[rstest]
269 fn test_build_ws_jwt_has_no_uri() {
270 let pem_key = test_sec1_pem_key();
271 let cred = CoinbaseCredential::new(TEST_API_KEY.to_string(), pem_key);
272 let jwt = cred.build_ws_jwt();
273 assert!(jwt.is_ok());
274
275 let token = jwt.unwrap();
276 let parts: Vec<&str> = token.split('.').collect();
277 let payload_bytes = BASE64_URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
278 let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
279 assert!(payload.get("uri").is_none());
280 }
281
282 #[rstest]
283 fn test_build_jwt_with_pkcs8_pem() {
284 let pem_key = test_pkcs8_pem_key();
285 let cred = CoinbaseCredential::new(TEST_API_KEY.to_string(), pem_key);
286 let jwt = cred.build_rest_jwt("GET api.coinbase.com/api/v3/brokerage/accounts");
287 assert!(jwt.is_ok());
288 }
289
290 #[rstest]
291 fn test_build_jwt_invalid_pem_fails() {
292 let cred = CoinbaseCredential::new(TEST_API_KEY.to_string(), "not-a-pem-key".to_string());
293 let result = cred.build_rest_jwt("GET api.coinbase.com/test");
294 assert!(result.is_err());
295 assert!(result.unwrap_err().is_auth_error());
296 }
297
298 #[rstest]
299 fn test_build_jwt_with_escaped_newline_pem() {
300 let pem_key = test_sec1_pem_key();
301
302 let escaped = pem_key.replace('\n', "\\n");
305 assert!(
306 escaped.contains("\\n"),
307 "test setup: must have literal backslash-n"
308 );
309
310 let cred = CoinbaseCredential::new(TEST_API_KEY.to_string(), escaped);
311 let result = cred.build_rest_jwt("GET api.coinbase.com/api/v3/brokerage/accounts");
312 assert!(
313 result.is_ok(),
314 "escaped-newline PEM must parse after normalization"
315 );
316 }
317
318 #[rstest]
319 fn test_base64url_encode() {
320 let encoded = base64url_encode(b"hello world");
321 assert!(!encoded.contains('='));
322 assert!(!encoded.contains('+'));
323 assert!(!encoded.contains('/'));
324 }
325
326 #[rstest]
327 fn test_credential_env_vars_returns_canonical_pair() {
328 assert_eq!(
329 credential_env_vars(),
330 ("COINBASE_API_KEY", "COINBASE_API_SECRET"),
331 );
332 }
333
334 #[rstest]
335 fn test_credential_resolve_with_explicit_values() {
336 let cred = CoinbaseCredential::resolve(Some("explicit-key"), Some("explicit-secret"))
337 .expect("both explicit values must resolve");
338 assert_eq!(cred.api_key(), "explicit-key");
339 assert_eq!(cred.api_secret(), "explicit-secret");
340 }
341}