Skip to main content

nautilus_binance/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//! Binance API credential handling and request signing.
17//!
18//! This module provides two types of credentials:
19//! - [`Credential`]: HMAC SHA256 signing for REST API and standard WebSocket
20//! - [`Ed25519Credential`]: Ed25519 signing for WebSocket API and SBE streams
21//!
22//! Credentials are resolved from standard environment variables
23//! (`BINANCE_API_KEY`/`BINANCE_API_SECRET`). The deprecated `*_ED25519_*`
24//! variables are no longer supported and will produce a clear error.
25
26#![allow(unused_assignments)] // Fields are used in methods; false positive on some toolchains
27
28use std::fmt::{Debug, Display};
29
30use aws_lc_rs::hmac;
31use ed25519_dalek::{Signature, Signer, SigningKey};
32use nautilus_core::{hex, string::secret::REDACTED};
33use zeroize::ZeroizeOnDrop;
34
35use super::enums::{BinanceEnvironment, BinanceProductType};
36
37/// Resolves API credentials from config or environment variables.
38///
39/// Checks standard environment variables:
40/// - Live: `BINANCE_API_KEY` / `BINANCE_API_SECRET`
41/// - Testnet (Spot): `BINANCE_TESTNET_API_KEY` / `BINANCE_TESTNET_API_SECRET`
42/// - Testnet (Futures): `BINANCE_FUTURES_TESTNET_API_KEY` / `BINANCE_FUTURES_TESTNET_API_SECRET`
43/// - Demo: `BINANCE_DEMO_API_KEY` / `BINANCE_DEMO_API_SECRET`
44///
45/// The deprecated `*_ED25519_*` environment variables are no longer supported.
46/// If detected, a clear error is returned with migration instructions.
47///
48/// # Errors
49///
50/// Returns an error if credentials cannot be resolved from config or environment.
51pub fn resolve_credentials(
52    config_api_key: Option<String>,
53    config_api_secret: Option<String>,
54    environment: BinanceEnvironment,
55    product_type: BinanceProductType,
56) -> anyhow::Result<(String, String)> {
57    if let (Some(key), Some(secret)) = (config_api_key.clone(), config_api_secret.clone()) {
58        return Ok((key, secret));
59    }
60
61    let (deprecated_key_var, deprecated_secret_var, standard_key_var, standard_secret_var) =
62        match environment {
63            BinanceEnvironment::Testnet => match product_type {
64                BinanceProductType::Spot
65                | BinanceProductType::Margin
66                | BinanceProductType::Options => (
67                    "BINANCE_TESTNET_ED25519_API_KEY",
68                    "BINANCE_TESTNET_ED25519_API_SECRET",
69                    "BINANCE_TESTNET_API_KEY",
70                    "BINANCE_TESTNET_API_SECRET",
71                ),
72                BinanceProductType::UsdM | BinanceProductType::CoinM => (
73                    "BINANCE_FUTURES_TESTNET_ED25519_API_KEY",
74                    "BINANCE_FUTURES_TESTNET_ED25519_API_SECRET",
75                    "BINANCE_FUTURES_TESTNET_API_KEY",
76                    "BINANCE_FUTURES_TESTNET_API_SECRET",
77                ),
78            },
79
80            // Demo shares API keys across all product types
81            BinanceEnvironment::Demo => ("", "", "BINANCE_DEMO_API_KEY", "BINANCE_DEMO_API_SECRET"),
82            BinanceEnvironment::Live => (
83                "BINANCE_ED25519_API_KEY",
84                "BINANCE_ED25519_API_SECRET",
85                "BINANCE_API_KEY",
86                "BINANCE_API_SECRET",
87            ),
88        };
89
90    // Futures: soft deprecation (warn + fallback),
91    // Spot/Margin: hard error on removed env vars.
92    let is_futures = matches!(
93        product_type,
94        BinanceProductType::UsdM | BinanceProductType::CoinM
95    );
96
97    let api_key = config_api_key
98        .or_else(|| std::env::var(standard_key_var).ok())
99        .or_else(|| resolve_deprecated_var(deprecated_key_var, standard_key_var, is_futures))
100        .ok_or_else(|| anyhow::anyhow!("{standard_key_var} not found in config or environment"))?;
101
102    let api_secret = config_api_secret
103        .or_else(|| std::env::var(standard_secret_var).ok())
104        .or_else(|| resolve_deprecated_var(deprecated_secret_var, standard_secret_var, is_futures))
105        .ok_or_else(|| {
106            anyhow::anyhow!("{standard_secret_var} not found in config or environment")
107        })?;
108
109    Ok((api_key, api_secret))
110}
111
112fn resolve_deprecated_var(
113    deprecated_var: &str,
114    standard_var: &str,
115    allow_fallback: bool,
116) -> Option<String> {
117    if deprecated_var.is_empty() {
118        return None;
119    }
120
121    let value = std::env::var(deprecated_var).ok()?;
122
123    if allow_fallback {
124        log::warn!(
125            "'{deprecated_var}' is deprecated and will be removed in a future version. \
126             Rename it to '{standard_var}' (Ed25519 keys are now auto-detected)"
127        );
128        Some(value)
129    } else {
130        log::error!(
131            "'{deprecated_var}' has been removed. \
132             Rename it to '{standard_var}' (Ed25519 keys are now auto-detected)"
133        );
134        None
135    }
136}
137
138/// Binance API credentials for signing requests (HMAC SHA256).
139///
140/// Uses HMAC SHA256 with hexadecimal encoding, as required by Binance REST API signing.
141#[derive(Clone, ZeroizeOnDrop)]
142pub struct Credential {
143    api_key: Box<str>,
144    api_secret: Box<[u8]>,
145}
146
147/// Binance Ed25519 credentials for WebSocket API authentication.
148///
149/// Ed25519 is required for WebSocket API authentication (`session.logon`).
150/// This is the only key type supported for execution clients.
151#[derive(ZeroizeOnDrop)]
152pub struct Ed25519Credential {
153    api_key: Box<str>,
154    signing_key: SigningKey,
155}
156
157impl Debug for Credential {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.debug_struct(stringify!(Credential))
160            .field("api_key", &self.api_key)
161            .field("api_secret", &REDACTED)
162            .finish()
163    }
164}
165
166impl Credential {
167    /// Creates a new [`Credential`] instance.
168    #[must_use]
169    pub fn new(api_key: String, api_secret: String) -> Self {
170        Self {
171            api_key: api_key.into_boxed_str(),
172            api_secret: api_secret.into_bytes().into_boxed_slice(),
173        }
174    }
175
176    /// Returns the API key.
177    #[must_use]
178    pub fn api_key(&self) -> &str {
179        &self.api_key
180    }
181
182    /// Signs a message with HMAC SHA256 and returns a lowercase hex digest.
183    #[must_use]
184    pub fn sign(&self, message: &str) -> String {
185        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret);
186        let tag = hmac::sign(&key, message.as_bytes());
187        hex::encode(tag.as_ref())
188    }
189}
190
191impl Debug for Ed25519Credential {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.debug_struct(stringify!(Ed25519Credential))
194            .field("api_key", &self.api_key)
195            .field("signing_key", &REDACTED)
196            .finish()
197    }
198}
199
200/// Ed25519 PKCS#8 OID bytes (1.3.101.112) in DER encoding.
201///
202/// This five-byte sequence appears inside every PKCS#8-wrapped Ed25519 private
203/// key. It is used to distinguish a genuine Ed25519 key from an arbitrary
204/// base64-encoded HMAC secret, which would otherwise produce a syntactically
205/// valid 32-byte signing seed and be silently misclassified.
206const ED25519_OID: [u8; 5] = [0x06, 0x03, 0x2B, 0x65, 0x70];
207
208impl Ed25519Credential {
209    /// Creates a new [`Ed25519Credential`] from API key and base64-encoded private key.
210    ///
211    /// The private key can be provided as:
212    /// - PKCS#8 DER format (48 bytes, as generated by OpenSSL)
213    /// - PEM format (with or without headers)
214    ///
215    /// Raw 32-byte Ed25519 seeds (without PKCS#8 wrapping) are rejected: every
216    /// 32-byte value is a mathematically valid seed, so accepting them would
217    /// silently misclassify any base64-decodable HMAC secret as Ed25519.
218    ///
219    /// For PKCS#8/PEM format, the 32-byte seed is extracted from the last 32 bytes.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if the private key is not valid base64, does not carry
224    /// the Ed25519 PKCS#8 OID, or is shorter than 32 bytes after decoding.
225    pub fn new(api_key: String, private_key_base64: &str) -> Result<Self, Ed25519CredentialError> {
226        // Strip PEM headers/footers if present
227        let key_data: String = private_key_base64
228            .lines()
229            .filter(|line| !line.starts_with("-----"))
230            .collect();
231
232        let private_key_bytes =
233            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &key_data)
234                .map_err(|e| Ed25519CredentialError::InvalidBase64(e.to_string()))?;
235
236        if !contains_subslice(&private_key_bytes, &ED25519_OID) {
237            return Err(Ed25519CredentialError::NotEd25519);
238        }
239
240        if private_key_bytes.len() < 32 {
241            return Err(Ed25519CredentialError::InvalidKeyLength);
242        }
243        let seed_start = private_key_bytes.len() - 32;
244        let key_bytes: [u8; 32] = private_key_bytes[seed_start..]
245            .try_into()
246            .map_err(|_| Ed25519CredentialError::InvalidKeyLength)?;
247
248        let signing_key = SigningKey::from_bytes(&key_bytes);
249
250        Ok(Self {
251            api_key: api_key.into_boxed_str(),
252            signing_key,
253        })
254    }
255
256    /// Returns the API key.
257    #[must_use]
258    pub fn api_key(&self) -> &str {
259        &self.api_key
260    }
261
262    /// Signs a message with Ed25519 and returns a base64-encoded signature.
263    #[must_use]
264    pub fn sign(&self, message: &[u8]) -> String {
265        let signature: Signature = self.signing_key.sign(message);
266        base64::Engine::encode(
267            &base64::engine::general_purpose::STANDARD,
268            signature.to_bytes(),
269        )
270    }
271}
272
273/// Error type for Ed25519 credential creation.
274#[derive(Debug, Clone)]
275pub enum Ed25519CredentialError {
276    /// The private key is not valid base64.
277    InvalidBase64(String),
278    /// The decoded key does not carry the Ed25519 PKCS#8 OID.
279    NotEd25519,
280    /// The private key is not 32 bytes.
281    InvalidKeyLength,
282}
283
284impl Display for Ed25519CredentialError {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        match self {
287            Self::InvalidBase64(e) => write!(f, "Invalid base64 encoding: {e}"),
288            Self::NotEd25519 => write!(f, "Decoded key does not carry the Ed25519 PKCS#8 OID"),
289            Self::InvalidKeyLength => write!(f, "Ed25519 private key must be 32 bytes"),
290        }
291    }
292}
293
294impl std::error::Error for Ed25519CredentialError {}
295
296/// Unified signing credential that auto-detects Ed25519 vs HMAC key type.
297///
298/// Binance supports two signing methods:
299/// - HMAC SHA256 (hex-encoded signature) for REST API and standard WebSocket
300/// - Ed25519 (base64-encoded signature) for WebSocket API and SBE streams
301///
302/// The key type is detected from the secret format: if the secret decodes as
303/// valid base64 with 32+ bytes (raw seed or PKCS#8), Ed25519 is used.
304/// Otherwise HMAC is used.
305#[derive(Clone)]
306pub enum SigningCredential {
307    /// HMAC SHA256 signing.
308    Hmac(Credential),
309    /// Ed25519 signing.
310    Ed25519(Box<Ed25519Credential>),
311}
312
313impl Debug for SigningCredential {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        match self {
316            Self::Hmac(c) => f.debug_tuple("Hmac").field(c).finish(),
317            Self::Ed25519(c) => f.debug_tuple("Ed25519").field(c).finish(),
318        }
319    }
320}
321
322impl SigningCredential {
323    /// Creates a new signing credential, auto-detecting Ed25519 vs HMAC.
324    ///
325    /// Tries Ed25519 first (base64-decoded secret must be a valid Ed25519 key).
326    /// Falls back to HMAC if Ed25519 parsing fails.
327    #[must_use]
328    pub fn new(api_key: String, api_secret: String) -> Self {
329        match Ed25519Credential::new(api_key.clone(), &api_secret) {
330            Ok(ed25519) => {
331                log::debug!("Auto-detected Ed25519 API key");
332                Self::Ed25519(Box::new(ed25519))
333            }
334            Err(_) => {
335                log::debug!("Using HMAC SHA256 API key");
336                Self::Hmac(Credential::new(api_key, api_secret))
337            }
338        }
339    }
340
341    /// Returns the API key.
342    #[must_use]
343    pub fn api_key(&self) -> &str {
344        match self {
345            Self::Hmac(c) => c.api_key(),
346            Self::Ed25519(c) => c.api_key(),
347        }
348    }
349
350    /// Signs a message string and returns the signature.
351    ///
352    /// For HMAC: returns lowercase hex digest.
353    /// For Ed25519: returns base64-encoded signature.
354    #[must_use]
355    pub fn sign(&self, message: &str) -> String {
356        match self {
357            Self::Hmac(c) => c.sign(message),
358            Self::Ed25519(c) => c.sign(message.as_bytes()),
359        }
360    }
361
362    /// Returns whether this credential uses Ed25519 signing.
363    #[must_use]
364    pub fn is_ed25519(&self) -> bool {
365        matches!(self, Self::Ed25519(_))
366    }
367}
368
369// Ed25519Credential does not implement Clone because SigningKey doesn't.
370// Provide a manual Clone for SigningCredential by re-deriving keys.
371impl Clone for Ed25519Credential {
372    fn clone(&self) -> Self {
373        // SigningKey is 32 bytes; extract and reconstruct
374        let key_bytes = self.signing_key.to_bytes();
375        Self {
376            api_key: self.api_key.clone(),
377            signing_key: SigningKey::from_bytes(&key_bytes),
378        }
379    }
380}
381
382fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
383    if needle.is_empty() || needle.len() > haystack.len() {
384        return false;
385    }
386    haystack.windows(needle.len()).any(|w| w == needle)
387}
388
389/// Builds the canonical query string that Binance's WebSocket API signs.
390///
391/// The WS API verifies a request's signature over its parameters **sorted by
392/// key**, so the signed query string must be key-sorted regardless of the order
393/// the parameters happen to iterate in. `serde_json`'s object backend is a
394/// sorted `BTreeMap` by default, but it silently becomes an insertion-ordered
395/// `IndexMap` if *any* crate anywhere in the build graph enables
396/// `serde_json/preserve_order` - a global Cargo feature-unification effect the
397/// adapter cannot control (e.g. a transitive `mongodb`/`bson` dependency).
398/// Signing the object in its raw iteration order therefore breaks intermittently
399/// with `-1022 Signature for this request is not valid` depending on unrelated
400/// dependencies. Sorting the keys here makes WS signing independent of that
401/// ambient feature.
402///
403/// This is not needed for the REST/HTTP path, which signs the exact query string
404/// it sends (Binance verifies REST signatures over the received order); only the
405/// WS API re-sorts before verifying.
406///
407/// See <https://github.com/nautechsystems/nautilus_trader/issues/4410>.
408pub(crate) fn canonical_ws_query_string<'a, I>(
409    params: I,
410) -> Result<String, serde_urlencoded::ser::Error>
411where
412    I: IntoIterator<Item = (&'a str, &'a serde_json::Value)>,
413{
414    // Collecting into a `BTreeMap` sorts by key regardless of the source order,
415    // and `serde_urlencoded` percent-encodes each value exactly as it would the
416    // original `serde_json::Value`.
417    let sorted: std::collections::BTreeMap<&str, &serde_json::Value> = params.into_iter().collect();
418    serde_urlencoded::to_string(sorted)
419}
420
421#[cfg(test)]
422mod tests {
423    use rstest::rstest;
424
425    use super::*;
426
427    // Official Binance test vectors from:
428    // https://github.com/binance/binance-signature-examples
429    const BINANCE_TEST_SECRET: &str =
430        "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";
431
432    #[rstest]
433    fn test_sign_matches_binance_test_vector_simple() {
434        let cred = Credential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
435        let message = "timestamp=1578963600000";
436        let expected = "d84e6641b1e328e7b418fff030caed655c266299c9355e36ce801ed14631eed4";
437
438        assert_eq!(cred.sign(message), expected);
439    }
440
441    #[rstest]
442    fn test_sign_matches_binance_test_vector_order() {
443        let cred = Credential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
444        let message = "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000&timestamp=1499827319559";
445        let expected = "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71";
446
447        assert_eq!(cred.sign(message), expected);
448    }
449
450    #[rstest]
451    fn test_canonical_ws_query_string_is_key_sorted_regardless_of_input_order() {
452        // Parameters in a deliberately non-alphabetical order - exactly what a
453        // `serde_json/preserve_order` (IndexMap) build yields, and what broke WS
454        // signing with -1022 (issue #4410). Binance verifies the WS signature
455        // over the *sorted* parameters, so the query string must come out
456        // key-sorted whatever order the caller supplied them in.
457        let symbol = serde_json::json!("LTCBTC");
458        let side = serde_json::json!("BUY");
459        let quantity = serde_json::json!("1");
460        let timestamp = serde_json::json!(1_499_827_319_559i64);
461        let api_key = serde_json::json!("mykey");
462        let unsorted = [
463            ("symbol", &symbol),
464            ("side", &side),
465            ("quantity", &quantity),
466            ("timestamp", &timestamp),
467            ("apiKey", &api_key),
468        ];
469
470        let query = canonical_ws_query_string(unsorted).unwrap();
471
472        assert_eq!(
473            query,
474            "apiKey=mykey&quantity=1&side=BUY&symbol=LTCBTC&timestamp=1499827319559"
475        );
476    }
477
478    #[rstest]
479    fn test_canonical_ws_query_string_preserves_urlencoding() {
480        let symbol = serde_json::json!("LTCBTC");
481        let new_client_order_id = serde_json::json!("desk alpha");
482        let unsorted = [
483            ("symbol", &symbol),
484            ("newClientOrderId", &new_client_order_id),
485        ];
486
487        let query = canonical_ws_query_string(unsorted).unwrap();
488
489        assert_eq!(query, "newClientOrderId=desk+alpha&symbol=LTCBTC");
490    }
491
492    #[rstest]
493    fn test_debug_redacts_secret() {
494        let cred = Credential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
495        let dbg_out = format!("{cred:?}");
496
497        assert!(dbg_out.contains(REDACTED));
498        assert!(!dbg_out.contains("NhqPtmdSJYdKjVHjA7PZj4"));
499    }
500
501    /// PKCS#8 DER wrapping of RFC 8032 test vector 1 Ed25519 private key.
502    ///
503    /// Structure: SEQUENCE { INTEGER 0, SEQUENCE { OID 1.3.101.112 },
504    /// OCTET STRING { OCTET STRING { 32 key bytes } } }.
505    const ED25519_PKCS8_TEST_VECTOR: [u8; 48] = [
506        0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04,
507        0x20, 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
508        0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c,
509        0xae, 0x7f, 0x60,
510    ];
511
512    #[rstest]
513    fn test_ed25519_matches_rfc_8032_vector() {
514        let key_b64 = base64::Engine::encode(
515            &base64::engine::general_purpose::STANDARD,
516            ED25519_PKCS8_TEST_VECTOR,
517        );
518
519        let cred = Ed25519Credential::new("test_key".to_string(), &key_b64).unwrap();
520
521        let signature = cred.sign(b"");
522
523        assert_eq!(
524            signature,
525            "5VZDAMNgrHKQhuLMgG6CioSHfx645dl02HPgZSJJAVVfuIIVkKM7rMYeOXAc+bRr0lv18FlbviRlUUFDjnoQCw=="
526        );
527    }
528
529    #[rstest]
530    fn test_ed25519_rejects_raw_32_byte_seed() {
531        // Raw 32-byte seeds decode fine but carry no PKCS#8 OID. Every
532        // 32-byte value is a mathematically valid seed, so accepting raw
533        // seeds would silently misclassify HMAC secrets as Ed25519.
534        let seed = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [0xABu8; 32]);
535
536        let result = Ed25519Credential::new("test_key".to_string(), &seed);
537
538        assert!(matches!(result, Err(Ed25519CredentialError::NotEd25519)));
539    }
540
541    #[rstest]
542    fn test_ed25519_rejects_binance_hmac_secret() {
543        // Regression: Binance HMAC secrets are 64-char base64 (48 bytes
544        // decoded). Before the OID check they matched the PKCS#8 length and
545        // were silently accepted as Ed25519, producing garbage signatures.
546        let result = Ed25519Credential::new("test_key".to_string(), BINANCE_TEST_SECRET);
547
548        assert!(matches!(result, Err(Ed25519CredentialError::NotEd25519)));
549    }
550
551    #[rstest]
552    fn test_signing_credential_autodetect_falls_back_to_hmac_on_binance_secret() {
553        // With the OID check in place, resolve_credentials picking an HMAC
554        // secret from the env vars now correctly routes through the HMAC
555        // signing path instead of generating a bogus Ed25519 signature.
556        let cred = SigningCredential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
557
558        assert!(matches!(cred, SigningCredential::Hmac(_)));
559    }
560
561    #[rstest]
562    fn test_ed25519_debug_redacts_secret() {
563        let key_b64 = base64::Engine::encode(
564            &base64::engine::general_purpose::STANDARD,
565            ED25519_PKCS8_TEST_VECTOR,
566        );
567
568        let cred = Ed25519Credential::new("test_key".to_string(), &key_b64).unwrap();
569        let dbg_out = format!("{cred:?}");
570
571        assert!(dbg_out.contains(REDACTED));
572        assert!(!dbg_out.contains(&key_b64));
573    }
574}