nautilus_coinbase_intx/common/
credential.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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
16#![allow(unused_assignments)] // Fields are used in sign_ws and accessed externally, false positive from nightly
17
18use std::fmt::Debug;
19
20use aws_lc_rs::hmac;
21use base64::prelude::*;
22use ustr::Ustr;
23use zeroize::ZeroizeOnDrop;
24
25/// Coinbase International API credentials for signing requests.
26///
27/// Uses HMAC SHA256 for request signing as per API specifications.
28/// Secrets are automatically zeroized on drop for security.
29#[derive(Clone, ZeroizeOnDrop)]
30pub struct Credential {
31    #[zeroize(skip)]
32    pub api_key: Ustr,
33    #[zeroize(skip)]
34    pub api_passphrase: Ustr,
35    api_secret: Box<[u8]>,
36}
37
38impl Debug for Credential {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct(stringify!(Credential))
41            .field("api_key", &self.api_key)
42            .field("api_passphrase", &self.api_passphrase)
43            .field("api_secret", &"<redacted>")
44            .finish()
45    }
46}
47
48impl Credential {
49    /// Creates a new [`Credential`] instance.
50    ///
51    /// # Panics
52    ///
53    /// Panics if the provided `api_secret` is not valid base64.
54    #[must_use]
55    pub fn new(api_key: String, api_secret: String, api_passphrase: String) -> Self {
56        let decoded_secret = BASE64_STANDARD
57            .decode(api_secret)
58            .expect("Invalid base64 secret key");
59
60        Self {
61            api_key: api_key.into(),
62            api_passphrase: api_passphrase.into(),
63            api_secret: decoded_secret.into_boxed_slice(),
64        }
65    }
66
67    /// Signs a request message according to the Coinbase authentication scheme.
68    ///
69    /// # Panics
70    ///
71    /// Panics if signature generation fails due to key or cryptographic errors.
72    #[must_use]
73    pub fn sign(&self, timestamp: &str, method: &str, endpoint: &str, body: &str) -> String {
74        // Extract the path without query parameters
75        let request_path = match endpoint.find('?') {
76            Some(index) => &endpoint[..index],
77            None => endpoint,
78        };
79
80        let message = format!("{timestamp}{method}{request_path}{body}");
81        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
82        let tag = hmac::sign(&key, message.as_bytes());
83        BASE64_STANDARD.encode(tag.as_ref())
84    }
85
86    /// Returns a masked version of the API key for logging purposes.
87    ///
88    /// Shows first 4 and last 4 characters with ellipsis in between.
89    /// For keys shorter than 8 characters, shows asterisks only.
90    #[must_use]
91    pub fn api_key_masked(&self) -> String {
92        nautilus_core::string::mask_api_key(self.api_key.as_str())
93    }
94
95    /// Signs a WebSocket authentication message.
96    ///
97    /// # Panics
98    ///
99    /// Panics if signature generation fails due to key or cryptographic errors.
100    pub fn sign_ws(&self, timestamp: &str) -> String {
101        let message = format!("{timestamp}{}CBINTLMD{}", self.api_key, self.api_passphrase);
102        tracing::trace!("Signing message: {message}");
103
104        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
105        let tag = hmac::sign(&key, message.as_bytes());
106        BASE64_STANDARD.encode(tag.as_ref())
107    }
108}
109
110////////////////////////////////////////////////////////////////////////////////
111// Tests
112////////////////////////////////////////////////////////////////////////////////
113
114#[cfg(test)]
115mod tests {
116    use rstest::rstest;
117
118    use super::*;
119
120    const API_KEY: &str = "test_key_123";
121    const API_SECRET: &str = "dGVzdF9zZWNyZXRfYmFzZTY0"; // base64 encoded "test_secret_base64"
122    const API_PASSPHRASE: &str = "test_pass";
123
124    #[rstest]
125    fn test_simple_get() {
126        let credential = Credential::new(
127            API_KEY.to_string(),
128            API_SECRET.to_string(),
129            API_PASSPHRASE.to_string(),
130        );
131        let timestamp = "1641890400"; // 2022-01-11T00:00:00Z
132        let signature = credential.sign(timestamp, "GET", "/api/v1/fee-rate-tiers", "");
133
134        assert_eq!(signature, "h/9tnYzD/nsEbH1sV7dkB5uJ3Vygr4TjmOOxJNQB8ts=");
135    }
136
137    #[rstest]
138    fn test_debug_redacts_secret() {
139        let credential = Credential::new(
140            API_KEY.to_string(),
141            API_SECRET.to_string(),
142            API_PASSPHRASE.to_string(),
143        );
144        let dbg_out = format!("{credential:?}");
145        assert!(dbg_out.contains("api_secret: \"<redacted>\""));
146        assert!(!dbg_out.contains("dGVz")); // base64 fragment
147        let secret_bytes_dbg = format!("{:?}", BASE64_STANDARD.decode(API_SECRET).unwrap());
148        assert!(
149            !dbg_out.contains(&secret_bytes_dbg),
150            "Debug output must not contain raw secret bytes"
151        );
152    }
153}