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