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::Mainnet => (
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::info!("Auto-detected Ed25519 API key");
332                Self::Ed25519(Box::new(ed25519))
333            }
334            Err(_) => {
335                log::info!("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#[cfg(test)]
390mod tests {
391    use rstest::rstest;
392
393    use super::*;
394
395    // Official Binance test vectors from:
396    // https://github.com/binance/binance-signature-examples
397    const BINANCE_TEST_SECRET: &str =
398        "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";
399
400    #[rstest]
401    fn test_sign_matches_binance_test_vector_simple() {
402        let cred = Credential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
403        let message = "timestamp=1578963600000";
404        let expected = "d84e6641b1e328e7b418fff030caed655c266299c9355e36ce801ed14631eed4";
405
406        assert_eq!(cred.sign(message), expected);
407    }
408
409    #[rstest]
410    fn test_sign_matches_binance_test_vector_order() {
411        let cred = Credential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
412        let message = "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000&timestamp=1499827319559";
413        let expected = "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71";
414
415        assert_eq!(cred.sign(message), expected);
416    }
417
418    #[rstest]
419    fn test_debug_redacts_secret() {
420        let cred = Credential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
421        let dbg_out = format!("{cred:?}");
422
423        assert!(dbg_out.contains(REDACTED));
424        assert!(!dbg_out.contains("NhqPtmdSJYdKjVHjA7PZj4"));
425    }
426
427    /// PKCS#8 DER wrapping of RFC 8032 test vector 1 Ed25519 private key.
428    ///
429    /// Structure: SEQUENCE { INTEGER 0, SEQUENCE { OID 1.3.101.112 },
430    /// OCTET STRING { OCTET STRING { 32 key bytes } } }.
431    const ED25519_PKCS8_TEST_VECTOR: [u8; 48] = [
432        0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04,
433        0x20, 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
434        0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c,
435        0xae, 0x7f, 0x60,
436    ];
437
438    #[rstest]
439    fn test_ed25519_accepts_pkcs8_wrapped_key() {
440        let key_b64 = base64::Engine::encode(
441            &base64::engine::general_purpose::STANDARD,
442            ED25519_PKCS8_TEST_VECTOR,
443        );
444
445        let cred = Ed25519Credential::new("test_key".to_string(), &key_b64).unwrap();
446
447        let signature = cred.sign(b"hello");
448        assert!(!signature.is_empty());
449    }
450
451    #[rstest]
452    fn test_ed25519_rejects_raw_32_byte_seed() {
453        // Raw 32-byte seeds decode fine but carry no PKCS#8 OID. Every
454        // 32-byte value is a mathematically valid seed, so accepting raw
455        // seeds would silently misclassify HMAC secrets as Ed25519.
456        let seed = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [0xABu8; 32]);
457
458        let result = Ed25519Credential::new("test_key".to_string(), &seed);
459
460        assert!(matches!(result, Err(Ed25519CredentialError::NotEd25519)));
461    }
462
463    #[rstest]
464    fn test_ed25519_rejects_binance_hmac_secret() {
465        // Regression: Binance HMAC secrets are 64-char base64 (48 bytes
466        // decoded). Before the OID check they matched the PKCS#8 length and
467        // were silently accepted as Ed25519, producing garbage signatures.
468        let result = Ed25519Credential::new("test_key".to_string(), BINANCE_TEST_SECRET);
469
470        assert!(matches!(result, Err(Ed25519CredentialError::NotEd25519)));
471    }
472
473    #[rstest]
474    fn test_signing_credential_autodetect_falls_back_to_hmac_on_binance_secret() {
475        // With the OID check in place, resolve_credentials picking an HMAC
476        // secret from the env vars now correctly routes through the HMAC
477        // signing path instead of generating a bogus Ed25519 signature.
478        let cred = SigningCredential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
479
480        assert!(matches!(cred, SigningCredential::Hmac(_)));
481    }
482
483    #[rstest]
484    fn test_ed25519_debug_redacts_secret() {
485        let key_b64 = base64::Engine::encode(
486            &base64::engine::general_purpose::STANDARD,
487            ED25519_PKCS8_TEST_VECTOR,
488        );
489
490        let cred = Ed25519Credential::new("test_key".to_string(), &key_b64).unwrap();
491        let dbg_out = format!("{cred:?}");
492
493        assert!(dbg_out.contains(REDACTED));
494        assert!(!dbg_out.contains(&key_b64));
495    }
496}