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 SBE market data streams
21
22#![allow(unused_assignments)] // Fields are used in methods; false positive on some toolchains
23
24use std::fmt::Debug;
25
26use aws_lc_rs::hmac;
27use ed25519_dalek::{Signature, Signer, SigningKey};
28use ustr::Ustr;
29use zeroize::ZeroizeOnDrop;
30
31/// Binance API credentials for signing requests (HMAC SHA256).
32///
33/// Uses HMAC SHA256 with hexadecimal encoding, as required by Binance REST API signing.
34#[derive(Clone, ZeroizeOnDrop)]
35pub struct Credential {
36    #[zeroize(skip)]
37    pub api_key: Ustr,
38    api_secret: Box<[u8]>,
39}
40
41/// Binance Ed25519 credentials for SBE market data streams.
42///
43/// SBE market data streams at `stream-sbe.binance.com` require Ed25519 API key
44/// authentication via the `X-MBX-APIKEY` header.
45#[derive(ZeroizeOnDrop)]
46pub struct Ed25519Credential {
47    #[zeroize(skip)]
48    pub api_key: Ustr,
49    signing_key: SigningKey,
50}
51
52impl Debug for Credential {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct(stringify!(Credential))
55            .field("api_key", &self.api_key)
56            .field("api_secret", &"<redacted>")
57            .finish()
58    }
59}
60
61impl Credential {
62    /// Creates a new [`Credential`] instance.
63    #[must_use]
64    pub fn new(api_key: String, api_secret: String) -> Self {
65        Self {
66            api_key: api_key.into(),
67            api_secret: api_secret.into_bytes().into_boxed_slice(),
68        }
69    }
70
71    /// Returns the API key.
72    #[must_use]
73    pub fn api_key(&self) -> &str {
74        self.api_key.as_str()
75    }
76
77    /// Signs a message with HMAC SHA256 and returns a lowercase hex digest.
78    #[must_use]
79    pub fn sign(&self, message: &str) -> String {
80        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret);
81        let tag = hmac::sign(&key, message.as_bytes());
82        hex::encode(tag.as_ref())
83    }
84}
85
86impl Debug for Ed25519Credential {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct(stringify!(Ed25519Credential))
89            .field("api_key", &self.api_key)
90            .field("signing_key", &"<redacted>")
91            .finish()
92    }
93}
94
95impl Ed25519Credential {
96    /// Creates a new [`Ed25519Credential`] from API key and base64-encoded private key.
97    ///
98    /// The private key can be provided as:
99    /// - Raw 32-byte seed (base64 encoded)
100    /// - PKCS#8 DER format (48 bytes, as generated by OpenSSL)
101    /// - PEM format (with or without headers)
102    ///
103    /// For PKCS#8/PEM format, the 32-byte seed is extracted from the last 32 bytes.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the private key is not valid base64 or not a valid
108    /// Ed25519 private key.
109    pub fn new(api_key: String, private_key_base64: &str) -> Result<Self, Ed25519CredentialError> {
110        // Strip PEM headers/footers if present
111        let key_data: String = private_key_base64
112            .lines()
113            .filter(|line| !line.starts_with("-----"))
114            .collect();
115
116        let private_key_bytes =
117            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &key_data)
118                .map_err(|e| Ed25519CredentialError::InvalidBase64(e.to_string()))?;
119
120        // Extract 32-byte seed: works for both raw (32 bytes) and PKCS#8 (48 bytes)
121        if private_key_bytes.len() < 32 {
122            return Err(Ed25519CredentialError::InvalidKeyLength);
123        }
124        let seed_start = private_key_bytes.len() - 32;
125        let key_bytes: [u8; 32] = private_key_bytes[seed_start..]
126            .try_into()
127            .map_err(|_| Ed25519CredentialError::InvalidKeyLength)?;
128
129        let signing_key = SigningKey::from_bytes(&key_bytes);
130
131        Ok(Self {
132            api_key: api_key.into(),
133            signing_key,
134        })
135    }
136
137    /// Returns the API key.
138    #[must_use]
139    pub fn api_key(&self) -> &str {
140        self.api_key.as_str()
141    }
142
143    /// Signs a message with Ed25519 and returns a base64-encoded signature.
144    #[must_use]
145    pub fn sign(&self, message: &[u8]) -> String {
146        let signature: Signature = self.signing_key.sign(message);
147        base64::Engine::encode(
148            &base64::engine::general_purpose::STANDARD,
149            signature.to_bytes(),
150        )
151    }
152}
153
154/// Error type for Ed25519 credential creation.
155#[derive(Debug, Clone)]
156pub enum Ed25519CredentialError {
157    /// The private key is not valid base64.
158    InvalidBase64(String),
159    /// The private key is not 32 bytes.
160    InvalidKeyLength,
161}
162
163impl std::fmt::Display for Ed25519CredentialError {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        match self {
166            Self::InvalidBase64(e) => write!(f, "Invalid base64 encoding: {e}"),
167            Self::InvalidKeyLength => write!(f, "Ed25519 private key must be 32 bytes"),
168        }
169    }
170}
171
172impl std::error::Error for Ed25519CredentialError {}
173
174#[cfg(test)]
175mod tests {
176    use rstest::rstest;
177
178    use super::*;
179
180    // Official Binance test vectors from:
181    // https://github.com/binance/binance-signature-examples
182    const BINANCE_TEST_SECRET: &str =
183        "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";
184
185    #[rstest]
186    fn test_sign_matches_binance_test_vector_simple() {
187        let cred = Credential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
188        let message = "timestamp=1578963600000";
189        let expected = "d84e6641b1e328e7b418fff030caed655c266299c9355e36ce801ed14631eed4";
190
191        assert_eq!(cred.sign(message), expected);
192    }
193
194    #[rstest]
195    fn test_sign_matches_binance_test_vector_order() {
196        let cred = Credential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
197        let message = "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000&timestamp=1499827319559";
198        let expected = "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71";
199
200        assert_eq!(cred.sign(message), expected);
201    }
202}