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