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