Skip to main content

nautilus_coinbase/common/
credential.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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/// Returns the `(api_key, api_secret)` environment variable names.
33#[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/// CDP API key pair with zeroization on drop.
43#[derive(Clone, Zeroize, ZeroizeOnDrop)]
44pub struct CoinbaseCredential {
45    api_key: String,
46    api_secret: String,
47}
48
49impl CoinbaseCredential {
50    /// Creates a new [`CoinbaseCredential`] instance.
51    pub fn new(api_key: String, api_secret: String) -> Self {
52        Self {
53            api_key,
54            api_secret,
55        }
56    }
57
58    /// Resolves credentials from provided values or [`credential_env_vars`],
59    /// returning `None` when neither yields a complete pair.
60    #[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    /// Loads credentials from environment variables.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`Error::Auth`] if the environment variables are unset or empty.
79    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    /// Returns the API key name.
89    pub fn api_key(&self) -> &str {
90        &self.api_key
91    }
92
93    /// Returns the PEM-encoded API secret.
94    pub fn api_secret(&self) -> &str {
95        &self.api_secret
96    }
97
98    /// Generates a JWT for REST API authentication.
99    ///
100    /// The `uri` format is `"{METHOD} {host}{path}"`, e.g.
101    /// `"GET api.coinbase.com/api/v3/brokerage/accounts"`.
102    pub fn build_rest_jwt(&self, uri: &str) -> Result<String> {
103        self.build_jwt(Some(uri))
104    }
105
106    /// Generates a JWT for WebSocket authentication (no URI claim).
107    pub fn build_ws_jwt(&self) -> Result<String> {
108        self.build_jwt(None)
109    }
110
111    /// Generates an ES256 JWT signed with the PEM EC private key.
112    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        // Env vars and .env files often store PEM keys with literal `\n`
148        // instead of real newlines. Normalize before parsing.
149        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        // Coinbase issues SEC1 (EC PRIVATE KEY) PEMs; from_private_key_der
155        // handles both SEC1 and PKCS#8 formats
156        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    /// Generates a SEC1 (RFC 5915) PEM key matching Coinbase's production format.
205    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    /// Generates a PKCS#8 PEM key.
216    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        // Decode and verify header
251        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        // Decode and verify payload
259        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        // Simulate the common env-var / .env-file pattern where real newlines
303        // are stored as literal two-char `\n` sequences.
304        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}