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