Skip to main content

nautilus_kraken/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
16//! Request signing and authentication credentials for the Kraken API.
17
18use std::{
19    collections::HashMap,
20    fmt::Debug,
21    sync::atomic::{AtomicU64, Ordering},
22    time::{SystemTime, UNIX_EPOCH},
23};
24
25use aws_lc_rs::{digest, hmac};
26use base64::{Engine, engine::general_purpose::STANDARD};
27use nautilus_core::{env::resolve_env_var_pair, string::secret::REDACTED};
28use serde_urlencoded;
29use zeroize::{Zeroize, ZeroizeOnDrop};
30
31use crate::common::enums::{KrakenEnvironment, KrakenProductType};
32
33/// Global atomic counter for nonce uniqueness within the same microsecond.
34static NONCE_COUNTER: AtomicU64 = AtomicU64::new(0);
35
36/// Returns the environment variable names for API credentials,
37/// based on the product type and environment.
38#[must_use]
39pub fn credential_env_vars(
40    product_type: KrakenProductType,
41    environment: KrakenEnvironment,
42) -> (&'static str, &'static str) {
43    match product_type {
44        KrakenProductType::Spot => ("KRAKEN_SPOT_API_KEY", "KRAKEN_SPOT_API_SECRET"),
45        KrakenProductType::Futures => {
46            if matches!(environment, KrakenEnvironment::Demo) {
47                (
48                    "KRAKEN_FUTURES_DEMO_API_KEY",
49                    "KRAKEN_FUTURES_DEMO_API_SECRET",
50                )
51            } else {
52                ("KRAKEN_FUTURES_API_KEY", "KRAKEN_FUTURES_API_SECRET")
53            }
54        }
55    }
56}
57
58/// API credentials for Kraken authentication.
59#[derive(Clone, Zeroize, ZeroizeOnDrop)]
60pub struct KrakenCredential {
61    api_key: String,
62    api_secret: String,
63}
64
65impl Debug for KrakenCredential {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct(stringify!(KrakenCredential))
68            .field("api_key", &self.api_key)
69            .field("api_secret", &REDACTED)
70            .finish()
71    }
72}
73
74impl KrakenCredential {
75    /// Creates a new credential with the given API key and secret.
76    pub fn new(api_key: impl Into<String>, api_secret: impl Into<String>) -> Self {
77        Self {
78            api_key: api_key.into(),
79            api_secret: api_secret.into(),
80        }
81    }
82
83    /// Load credentials from environment variables for Kraken Spot.
84    ///
85    /// Looks for `KRAKEN_SPOT_API_KEY` and `KRAKEN_SPOT_API_SECRET`.
86    ///
87    /// Note: Kraken Spot does not have a testnet environment.
88    ///
89    /// Returns `None` if either key or secret is not set.
90    #[must_use]
91    pub fn from_env_spot() -> Option<Self> {
92        let (key_var, secret_var) =
93            credential_env_vars(KrakenProductType::Spot, KrakenEnvironment::Mainnet);
94        let (k, s) = resolve_env_var_pair(None, None, key_var, secret_var)?;
95        Some(Self::new(k, s))
96    }
97
98    /// Load credentials from environment variables for Kraken Futures.
99    ///
100    /// Looks for `KRAKEN_FUTURES_API_KEY` and `KRAKEN_FUTURES_API_SECRET` (mainnet)
101    /// or `KRAKEN_FUTURES_DEMO_API_KEY` and `KRAKEN_FUTURES_DEMO_API_SECRET` (demo).
102    ///
103    /// Returns `None` if either key or secret is not set.
104    #[must_use]
105    pub fn from_env_futures(demo: bool) -> Option<Self> {
106        let environment = if demo {
107            KrakenEnvironment::Demo
108        } else {
109            KrakenEnvironment::Mainnet
110        };
111        let (key_var, secret_var) = credential_env_vars(KrakenProductType::Futures, environment);
112        let (k, s) = resolve_env_var_pair(None, None, key_var, secret_var)?;
113        Some(Self::new(k, s))
114    }
115
116    /// Resolves credentials from provided values or environment for Spot.
117    ///
118    /// If both `api_key` and `api_secret` are provided, uses those.
119    /// Otherwise falls back to loading from environment variables.
120    #[must_use]
121    pub fn resolve_spot(api_key: Option<String>, api_secret: Option<String>) -> Option<Self> {
122        let (key_var, secret_var) =
123            credential_env_vars(KrakenProductType::Spot, KrakenEnvironment::Mainnet);
124        let (k, s) = resolve_env_var_pair(api_key, api_secret, key_var, secret_var)?;
125        Some(Self::new(k, s))
126    }
127
128    /// Resolves credentials from provided values or environment for Futures.
129    ///
130    /// If both `api_key` and `api_secret` are provided, uses those.
131    /// Otherwise falls back to loading from environment variables.
132    #[must_use]
133    pub fn resolve_futures(
134        api_key: Option<String>,
135        api_secret: Option<String>,
136        demo: bool,
137    ) -> Option<Self> {
138        let environment = if demo {
139            KrakenEnvironment::Demo
140        } else {
141            KrakenEnvironment::Mainnet
142        };
143        let (key_var, secret_var) = credential_env_vars(KrakenProductType::Futures, environment);
144        let (k, s) = resolve_env_var_pair(api_key, api_secret, key_var, secret_var)?;
145        Some(Self::new(k, s))
146    }
147
148    /// Returns the API key.
149    pub fn api_key(&self) -> &str {
150        &self.api_key
151    }
152
153    /// Returns the API key and secret as cloned strings.
154    pub fn into_parts(&self) -> (String, String) {
155        (self.api_key.clone(), self.api_secret.clone())
156    }
157
158    /// Sign a request for Kraken Spot REST API.
159    ///
160    /// Kraken Spot uses HMAC-SHA512 with the following message:
161    /// - path + SHA256(nonce + POST data)
162    /// - The secret is base64 decoded before signing
163    ///
164    /// Note: "nonce + POST data" means the nonce value string is prepended
165    /// to the URL-encoded POST body, e.g., "1234567890nonce=1234567890&param=value".
166    pub fn sign_spot(
167        &self,
168        path: &str,
169        nonce: u64,
170        params: &HashMap<String, String>,
171    ) -> anyhow::Result<(String, String)> {
172        let secret = STANDARD
173            .decode(&self.api_secret)
174            .map_err(|e| anyhow::anyhow!("Failed to decode API secret: {e}"))?;
175
176        let nonce_str = nonce.to_string();
177        let mut post_data = format!("nonce={nonce_str}");
178
179        if !params.is_empty() {
180            let encoded = serde_urlencoded::to_string(params)
181                .map_err(|e| anyhow::anyhow!("Failed to encode params: {e}"))?;
182            post_data.push('&');
183            post_data.push_str(&encoded);
184        }
185
186        let sha_input = format!("{nonce_str}{post_data}");
187        let hash = digest::digest(&digest::SHA256, sha_input.as_bytes());
188        let mut message = path.as_bytes().to_vec();
189        message.extend_from_slice(hash.as_ref());
190        let key = hmac::Key::new(hmac::HMAC_SHA512, &secret);
191        let signature = hmac::sign(&key, &message);
192
193        Ok((STANDARD.encode(signature.as_ref()), post_data))
194    }
195
196    /// Sign a JSON request for Kraken Spot API (used for CancelOrderBatch, AddOrderBatch).
197    ///
198    /// These endpoints use JSON body instead of form-encoded.
199    /// Signature: HMAC-SHA512(path + SHA256(nonce + json_body))
200    pub fn sign_spot_json(
201        &self,
202        path: &str,
203        nonce: u64,
204        json_body: &str,
205    ) -> anyhow::Result<String> {
206        let secret = STANDARD
207            .decode(&self.api_secret)
208            .map_err(|e| anyhow::anyhow!("Failed to decode API secret: {e}"))?;
209
210        let nonce_str = nonce.to_string();
211        let sha_input = format!("{nonce_str}{json_body}");
212        let hash = digest::digest(&digest::SHA256, sha_input.as_bytes());
213        let mut message = path.as_bytes().to_vec();
214        message.extend_from_slice(hash.as_ref());
215        let key = hmac::Key::new(hmac::HMAC_SHA512, &secret);
216        let signature = hmac::sign(&key, &message);
217
218        Ok(STANDARD.encode(signature.as_ref()))
219    }
220
221    /// Sign a request for Kraken Futures API v3.
222    ///
223    /// Kraken Futures authentication steps:
224    /// 1. Strip "/derivatives" prefix from endpoint path
225    /// 2. Concatenate: `postData + nonce + endpointPath`
226    /// 3. SHA-256 hash the concatenation
227    /// 4. Base64 decode the API secret
228    /// 5. HMAC-SHA-512 of the SHA-256 hash using decoded secret
229    /// 6. Base64 encode the result
230    ///
231    /// # References
232    /// - <https://docs.kraken.com/api/docs/guides/futures-rest/>
233    pub fn sign_futures(&self, path: &str, post_data: &str, nonce: u64) -> anyhow::Result<String> {
234        let secret = STANDARD
235            .decode(&self.api_secret)
236            .map_err(|e| anyhow::anyhow!("Failed to decode API secret: {e}"))?;
237
238        let signing_path = path.strip_prefix("/derivatives").unwrap_or(path);
239        let message = format!("{post_data}{nonce}{signing_path}");
240        let hash = digest::digest(&digest::SHA256, message.as_bytes());
241        let key = hmac::Key::new(hmac::HMAC_SHA512, &secret);
242        let signature = hmac::sign(&key, hash.as_ref());
243
244        Ok(STANDARD.encode(signature.as_ref()))
245    }
246
247    /// Sign a WebSocket challenge for Kraken Futures private feeds.
248    ///
249    /// The signing process is similar to REST API authentication:
250    /// 1. SHA-256 hash the challenge string
251    /// 2. HMAC-SHA-512 of the hash using decoded API secret
252    /// 3. Base64 encode the result
253    pub fn sign_ws_challenge(&self, challenge: &str) -> anyhow::Result<String> {
254        let secret = STANDARD
255            .decode(&self.api_secret)
256            .map_err(|e| anyhow::anyhow!("Failed to decode API secret: {e}"))?;
257
258        let hash = digest::digest(&digest::SHA256, challenge.as_bytes());
259        let key = hmac::Key::new(hmac::HMAC_SHA512, &secret);
260        let signature = hmac::sign(&key, hash.as_ref());
261
262        Ok(STANDARD.encode(signature.as_ref()))
263    }
264
265    /// Returns a masked version of the API key for logging purposes.
266    ///
267    /// Shows first 4 and last 4 characters with ellipsis in between.
268    /// For keys shorter than 8 characters, shows asterisks only.
269    #[must_use]
270    pub fn api_key_masked(&self) -> String {
271        nautilus_core::string::secret::mask_api_key(&self.api_key)
272    }
273}
274
275/// Generates a unique nonce for Kraken API requests.
276///
277/// Combines microseconds since epoch with an atomic counter to guarantee uniqueness
278/// even when multiple requests are made within the same microsecond.
279#[must_use]
280pub fn generate_nonce() -> u64 {
281    let micros = SystemTime::now()
282        .duration_since(UNIX_EPOCH)
283        .expect("System time before UNIX epoch")
284        .as_micros() as u64;
285
286    let counter = NONCE_COUNTER.fetch_add(1, Ordering::Relaxed);
287    micros.wrapping_add(counter)
288}
289
290#[cfg(test)]
291mod tests {
292    use rstest::rstest;
293
294    use super::*;
295
296    #[rstest]
297    fn test_credential_creation() {
298        let cred = KrakenCredential::new("test_key", "test_secret");
299        assert_eq!(cred.api_key(), "test_key");
300    }
301
302    #[rstest]
303    fn test_generate_nonce_uniqueness() {
304        let nonces: Vec<u64> = (0..1000).map(|_| generate_nonce()).collect();
305        let unique: std::collections::HashSet<u64> = nonces.iter().copied().collect();
306        assert_eq!(
307            nonces.len(),
308            unique.len(),
309            "Generated nonces should be unique"
310        );
311    }
312
313    #[rstest]
314    fn test_sign_futures_uses_url_encoded_post_data() {
315        // This test documents that sign_futures expects URL-encoded post data,
316        // which must match the body actually sent in the HTTP request.
317        // Using a valid base64-encoded secret (24 bytes -> 32 base64 chars)
318        let secret = STANDARD.encode(b"test_secret_key_24bytes!");
319        let cred = KrakenCredential::new("test_key", secret);
320
321        let endpoint = "/derivatives/api/v3/sendorder";
322        let nonce = 1234567890u64;
323
324        // Create params and URL-encode them (same format as HTTP client)
325        let mut params = HashMap::new();
326        params.insert("symbol".to_string(), "PI_XBTUSD".to_string());
327        params.insert("side".to_string(), "buy".to_string());
328        params.insert("orderType".to_string(), "lmt".to_string());
329        params.insert("size".to_string(), "100".to_string());
330        params.insert("limitPrice".to_string(), "50000.5".to_string());
331
332        let post_data = serde_urlencoded::to_string(&params).unwrap();
333
334        // Signature is: SHA256(postData + nonce + path) -> HMAC-SHA512 -> base64
335        let signature = cred.sign_futures(endpoint, &post_data, nonce).unwrap();
336
337        // Signature should be non-empty base64
338        assert!(!signature.is_empty());
339        assert!(STANDARD.decode(&signature).is_ok());
340
341        // Same params and nonce should produce same signature (deterministic)
342        let signature2 = cred.sign_futures(endpoint, &post_data, nonce).unwrap();
343        assert_eq!(signature, signature2);
344
345        // Different post_data should produce different signature
346        let different_post_data = "symbol=PI_ETHUSD&side=sell";
347        let different_sig = cred
348            .sign_futures(endpoint, different_post_data, nonce)
349            .unwrap();
350        assert_ne!(signature, different_sig);
351
352        // Different nonce should produce different signature
353        let different_nonce_sig = cred.sign_futures(endpoint, &post_data, nonce + 1).unwrap();
354        assert_ne!(signature, different_nonce_sig);
355    }
356
357    #[rstest]
358    fn test_sign_futures_strips_derivatives_prefix() {
359        // Verify that /derivatives prefix is stripped before signing
360        let secret = STANDARD.encode(b"test_secret_key_24bytes!");
361        let cred = KrakenCredential::new("test_key", secret);
362        let nonce = 1234567890u64;
363
364        // Signing with /derivatives prefix should produce same result as without
365        let with_prefix = cred
366            .sign_futures("/derivatives/api/v3/openpositions", "", nonce)
367            .unwrap();
368        let without_prefix = cred
369            .sign_futures("/api/v3/openpositions", "", nonce)
370            .unwrap();
371
372        assert_eq!(with_prefix, without_prefix);
373    }
374
375    #[rstest]
376    fn test_resolve_spot_with_both_args() {
377        let result =
378            KrakenCredential::resolve_spot(Some("key".to_string()), Some("secret".to_string()));
379        assert!(result.is_some());
380        let cred = result.unwrap();
381        assert_eq!(cred.api_key(), "key");
382    }
383
384    #[rstest]
385    fn test_resolve_spot_with_partial_args() {
386        let (_, secret_var) =
387            credential_env_vars(KrakenProductType::Spot, KrakenEnvironment::Mainnet);
388
389        // Each field resolves independently: provided key + env secret
390        if std::env::var(secret_var).is_ok() {
391            let result = KrakenCredential::resolve_spot(Some("key".to_string()), None);
392            assert!(result.is_some());
393            assert_eq!(result.unwrap().api_key(), "key");
394        } else {
395            let result = KrakenCredential::resolve_spot(Some("key".to_string()), None);
396            assert!(result.is_none());
397        }
398    }
399
400    #[rstest]
401    fn test_resolve_futures_with_both_args() {
402        let result = KrakenCredential::resolve_futures(
403            Some("key".to_string()),
404            Some("secret".to_string()),
405            false,
406        );
407        assert!(result.is_some());
408        let cred = result.unwrap();
409        assert_eq!(cred.api_key(), "key");
410    }
411
412    #[rstest]
413    fn test_resolve_futures_with_partial_args() {
414        let (_, secret_var) =
415            credential_env_vars(KrakenProductType::Futures, KrakenEnvironment::Mainnet);
416
417        // Each field resolves independently: provided key + env secret
418        if std::env::var(secret_var).is_ok() {
419            let result = KrakenCredential::resolve_futures(Some("key".to_string()), None, false);
420            assert!(result.is_some());
421            assert_eq!(result.unwrap().api_key(), "key");
422        } else {
423            let result = KrakenCredential::resolve_futures(Some("key".to_string()), None, false);
424            assert!(result.is_none());
425        }
426    }
427
428    #[rstest]
429    fn test_debug_redacts_secret() {
430        let cred = KrakenCredential::new("test_key", "test_secret");
431        let dbg_out = format!("{cred:?}");
432
433        assert!(dbg_out.contains(REDACTED));
434        assert!(!dbg_out.contains("test_secret"));
435    }
436}