rusty_feeder/exchange/bithumb/
auth.rs

1/*
2 * Bithumb authentication implementation
3 * Provides API signature generation for REST API and WebSocket endpoints
4 */
5
6use anyhow::{Result, anyhow};
7use base64::{Engine as _, engine::general_purpose};
8use hmac::{Hmac, Mac};
9use sha2::Sha512;
10use smartstring::alias::String;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13/// Type alias for HMAC-SHA512
14type HmacSha512 = Hmac<Sha512>;
15
16/// Bithumb authentication configuration
17#[derive(Debug, Clone)]
18pub struct BithumbAuthConfig {
19    /// API key
20    pub api_key: String,
21
22    /// API secret key
23    pub api_secret: String,
24}
25
26impl BithumbAuthConfig {
27    /// Create a new authentication configuration
28    #[must_use]
29    pub const fn new(api_key: String, api_secret: String) -> Self {
30        Self {
31            api_key,
32            api_secret,
33        }
34    }
35}
36
37/// Bithumb authentication handler
38#[derive(Debug)]
39pub struct BithumbAuth {
40    /// Authentication configuration
41    config: BithumbAuthConfig,
42}
43
44impl BithumbAuth {
45    /// Create a new authentication handler
46    #[must_use]
47    pub const fn new(config: BithumbAuthConfig) -> Self {
48        Self { config }
49    }
50
51    /// Generate API nonce (timestamp in milliseconds)
52    ///
53    /// # Returns
54    /// Current timestamp in milliseconds as u64
55    #[inline]
56    pub fn generate_nonce() -> u64 {
57        SystemTime::now()
58            .duration_since(UNIX_EPOCH)
59            .unwrap_or_default()
60            .as_millis() as u64
61    }
62
63    /// URL-encode the provided string into a preallocated buffer.
64    ///
65    /// This avoids heap allocations on critical paths.
66    pub fn encode_params_zero_copy(input: &str, buffer: &mut String) -> Result<()> {
67        buffer.clear();
68        const HEX: &[u8; 16] = b"0123456789ABCDEF";
69        for &byte in input.as_bytes() {
70            match byte {
71                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
72                    buffer.push(byte as char)
73                }
74                _ => {
75                    buffer.push('%');
76                    buffer.push(HEX[(byte >> 4) as usize] as char);
77                    buffer.push(HEX[(byte & 0x0F) as usize] as char);
78                }
79            }
80        }
81        Ok(())
82    }
83
84    /// Generate API signature for REST API requests
85    ///
86    /// # Parameters
87    /// - `endpoint`: The API endpoint path (e.g., "/info/balance")
88    /// - `params`: Request parameters as URL-encoded String (e.g., "currency=BTC")
89    /// - `nonce`: The API nonce (timestamp in milliseconds)
90    ///
91    /// # Returns
92    /// Base64-encoded HMAC-SHA512 signature
93    pub fn generate_signature(&self, endpoint: &str, params: &str, nonce: u64) -> Result<String> {
94        // Build the prehash String according to Bithumb specification
95        // Format: endpoint + chr(0) + params + chr(0) + nonce
96        let prehash = format!("{endpoint}\0{params}\0{nonce}");
97
98        // Create HMAC-SHA512
99        let mut mac = HmacSha512::new_from_slice(self.config.api_secret.as_bytes())
100            .map_err(|e| anyhow!("Failed to create HMAC: {}", e))?;
101
102        // Update with the prehash String
103        mac.update(prehash.as_bytes());
104
105        // Get the result and encode as base64
106        let result = mac.finalize();
107        let signature = general_purpose::STANDARD.encode(result.into_bytes());
108
109        Ok(signature.into())
110    }
111
112    /// Build URL-encoded parameter String from key-value pairs
113    ///
114    /// # Parameters
115    /// - `params`: Parameters as key-value pairs
116    ///
117    /// # Returns
118    /// URL-encoded parameter String
119    pub fn build_param_string(params: &[(&str, &str)]) -> Result<String> {
120        if params.is_empty() {
121            return Ok(String::new());
122        }
123
124        let mut encoded = String::new();
125        let mut param_parts = Vec::with_capacity(params.len());
126        for (k, v) in params {
127            Self::encode_params_zero_copy(v, &mut encoded)?;
128            param_parts.push(format!("{k}={encoded}"));
129        }
130
131        Ok(param_parts.join("&").into())
132    }
133
134    /// Generate headers for authenticated REST API request
135    ///
136    /// # Parameters
137    /// - `endpoint`: The API endpoint path
138    /// - `params`: Request parameters as key-value pairs (optional)
139    ///
140    /// # Returns
141    /// Tuple of (api_key, api_sign, api_nonce)
142    pub fn generate_auth_headers(
143        &self,
144        endpoint: &str,
145        params: Option<&[(&str, &str)]>,
146    ) -> Result<(String, String, u64)> {
147        // Generate nonce
148        let nonce = Self::generate_nonce();
149
150        // Build parameter String
151        let param_string = if let Some(p) = params {
152            Self::build_param_string(p)?
153        } else {
154            String::new()
155        };
156
157        // Generate signature
158        let signature = self.generate_signature(endpoint, &param_string, nonce)?;
159
160        Ok((self.config.api_key.clone(), signature, nonce))
161    }
162
163    /// Generate authentication for WebSocket connection
164    ///
165    /// # Returns
166    /// Authentication data for WebSocket
167    pub fn generate_websocket_auth(&self) -> Result<BithumbWebSocketAuth> {
168        let nonce = Self::generate_nonce();
169
170        // For WebSocket, the endpoint is usually empty or specific to the connection
171        let endpoint = "";
172        let params = "";
173
174        let signature = self.generate_signature(endpoint, params, nonce)?;
175
176        Ok(BithumbWebSocketAuth {
177            api_key: self.config.api_key.clone(),
178            api_sign: signature,
179            api_nonce: nonce,
180        })
181    }
182}
183
184/// WebSocket authentication data
185#[derive(Debug, Clone)]
186pub struct BithumbWebSocketAuth {
187    /// API key
188    pub api_key: String,
189
190    /// API signature
191    pub api_sign: String,
192
193    /// API nonce
194    pub api_nonce: u64,
195}
196
197/// Helper function to add authentication headers to HTTP request
198pub fn add_auth_to_request(
199    headers: &mut reqwest::header::HeaderMap,
200    api_key: &str,
201    api_sign: &str,
202    api_nonce: u64,
203) -> Result<()> {
204    use reqwest::header::{HeaderName, HeaderValue};
205
206    headers.insert(
207        HeaderName::from_static("api-key"),
208        HeaderValue::from_str(api_key).map_err(|e| anyhow!("Invalid API key: {}", e))?,
209    );
210
211    headers.insert(
212        HeaderName::from_static("api-sign"),
213        HeaderValue::from_str(api_sign).map_err(|e| anyhow!("Invalid API signature: {}", e))?,
214    );
215
216    headers.insert(
217        HeaderName::from_static("api-nonce"),
218        HeaderValue::from_str(&api_nonce.to_string())
219            .map_err(|e| anyhow!("Invalid API nonce: {}", e))?,
220    );
221
222    Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_nonce_generation() {
231        let nonce1 = BithumbAuth::generate_nonce();
232        std::thread::sleep(std::time::Duration::from_millis(1));
233        let nonce2 = BithumbAuth::generate_nonce();
234
235        // Nonces should be different and increasing
236        assert!(nonce2 > nonce1);
237    }
238
239    #[test]
240    fn test_signature_generation() {
241        let config = BithumbAuthConfig::new("test_api_key".into(), "test_api_secret".into());
242
243        let auth = BithumbAuth::new(config);
244
245        // Test signature generation
246        let endpoint = "/info/balance";
247        let params = "currency=BTC";
248        let nonce = 1234567890123u64;
249
250        let signature = auth.generate_signature(endpoint, params, nonce).unwrap();
251        assert!(!signature.is_empty());
252
253        // Signature should be base64 encoded
254        assert!(general_purpose::STANDARD.decode(&signature).is_ok());
255    }
256
257    #[test]
258    fn test_param_string_building() {
259        let params = vec![
260            ("currency", "BTC"),
261            ("order_currency", "KRW"),
262            ("payment_currency", "KRW"),
263        ];
264
265        let param_string = BithumbAuth::build_param_string(&params).unwrap();
266        assert_eq!(
267            param_string,
268            "currency=BTC&order_currency=KRW&payment_currency=KRW"
269        );
270    }
271
272    #[test]
273    fn test_auth_headers_generation() {
274        let config = BithumbAuthConfig::new("test_api_key".into(), "test_api_secret".into());
275
276        let auth = BithumbAuth::new(config);
277
278        let endpoint = "/info/balance";
279        let params = vec![("currency", "BTC")];
280
281        let (api_key, api_sign, api_nonce) =
282            auth.generate_auth_headers(endpoint, Some(&params)).unwrap();
283
284        assert_eq!(api_key, "test_api_key");
285        assert!(!api_sign.is_empty());
286        assert!(api_nonce > 0);
287    }
288}