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::{
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/// Returns the `(api_key, api_secret)` environment variable names.
36#[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/// CDP API key pair with zeroization on drop.
46#[derive(Clone, Zeroize, ZeroizeOnDrop)]
47pub struct CoinbaseCredential {
48    api_key: String,
49    api_secret: String,
50}
51
52impl CoinbaseCredential {
53    /// Creates a new [`CoinbaseCredential`] instance.
54    pub fn new(api_key: String, api_secret: String) -> Self {
55        Self {
56            api_key,
57            api_secret,
58        }
59    }
60
61    /// Resolves credentials from provided values or [`credential_env_vars`],
62    /// returning `None` when neither yields a complete pair.
63    #[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    /// Loads credentials from environment variables.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`Error::Auth`] if the environment variables are unset or empty.
82    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    /// Returns the API key name.
92    pub fn api_key(&self) -> &str {
93        &self.api_key
94    }
95
96    /// Returns the PEM-encoded API secret.
97    pub fn api_secret(&self) -> &str {
98        &self.api_secret
99    }
100
101    /// Generates a JWT for REST API authentication.
102    ///
103    /// The `uri` format is `"{METHOD} {host}{path}"`, e.g.
104    /// `"GET api.coinbase.com/api/v3/brokerage/accounts"`.
105    pub fn build_rest_jwt(&self, uri: &str) -> Result<SecretString> {
106        self.build_jwt(Some(uri))
107    }
108
109    /// Generates a JWT for WebSocket authentication (no URI claim).
110    pub fn build_ws_jwt(&self) -> Result<SecretString> {
111        self.build_jwt(None)
112    }
113
114    /// Generates an ES256 JWT signed with the PEM EC private key.
115    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        // Env vars and .env files often store PEM keys with literal `\n`
151        // instead of real newlines. Normalize before parsing.
152        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        // Coinbase issues SEC1 (EC PRIVATE KEY) PEMs; from_private_key_der
158        // handles both SEC1 and PKCS#8 formats
159        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    /// Generates a SEC1 (RFC 5915) PEM key matching Coinbase's production format.
201    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    /// Generates a PKCS#8 PEM key.
212    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        // Decode and verify header
249        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        // Decode and verify payload
257        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        // Simulate the common env-var / .env-file pattern where real newlines
301        // are stored as literal two-char `\n` sequences.
302        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}