Skip to main content

wickra_exchange_core/
credentials.rs

1//! API credentials, with secret hygiene.
2//!
3//! [`Credentials`] is the single, uniform input across every venue: a key and a
4//! secret, plus an optional passphrase (OKX, Bitget, KuCoin) and an optional
5//! private key (Coinbase's per-request JWT). The signing scheme behind each
6//! field is venue-specific, but the input shape is identical.
7//!
8//! Secret material is wiped from memory on drop (`zeroize`) and never appears in
9//! a `Debug` rendering — the manual [`Debug`] impl redacts every field, so a
10//! `Credentials` accidentally logged or embedded in an error reveals nothing.
11
12use crate::error::{Error, Result};
13use std::fmt;
14use zeroize::ZeroizeOnDrop;
15
16/// API credentials for one account on one venue.
17///
18/// Construct with [`Credentials::new`] and, where the venue requires it, add a
19/// passphrase or private key. The secret fields are crate-private: exchange
20/// modules read them to sign requests, but they are never exposed to consumers.
21#[derive(Clone, ZeroizeOnDrop)]
22pub struct Credentials {
23    pub(crate) api_key: String,
24    pub(crate) api_secret: String,
25    pub(crate) passphrase: Option<String>,
26    pub(crate) private_key: Option<String>,
27}
28
29impl Credentials {
30    /// Build credentials from an API key and secret.
31    pub fn new(api_key: impl Into<String>, api_secret: impl Into<String>) -> Self {
32        Self {
33            api_key: api_key.into(),
34            api_secret: api_secret.into(),
35            passphrase: None,
36            private_key: None,
37        }
38    }
39
40    /// Attach the passphrase required by OKX, Bitget and KuCoin.
41    #[must_use]
42    pub fn with_passphrase(mut self, passphrase: impl Into<String>) -> Self {
43        self.passphrase = Some(passphrase.into());
44        self
45    }
46
47    /// Attach the private key used for Coinbase's per-request JWT signing.
48    #[must_use]
49    pub fn with_private_key(mut self, private_key: impl Into<String>) -> Self {
50        self.private_key = Some(private_key.into());
51        self
52    }
53
54    /// Validate that the non-secret invariants hold: a non-empty key and secret.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`Error::InvalidCredentials`] if the key or secret is empty.
59    pub fn validate(&self) -> Result<()> {
60        if self.api_key.is_empty() {
61            return Err(Error::InvalidCredentials("api key must not be empty"));
62        }
63        if self.api_secret.is_empty() {
64            return Err(Error::InvalidCredentials("api secret must not be empty"));
65        }
66        Ok(())
67    }
68
69    /// Whether a passphrase is present (without revealing it).
70    #[must_use]
71    pub fn has_passphrase(&self) -> bool {
72        self.passphrase.is_some()
73    }
74
75    /// Whether a private key is present (without revealing it).
76    #[must_use]
77    pub fn has_private_key(&self) -> bool {
78        self.private_key.is_some()
79    }
80}
81
82impl fmt::Debug for Credentials {
83    /// Redacts every field. Presence of the optional fields is shown, their
84    /// contents never are.
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.debug_struct("Credentials")
87            .field("api_key", &"<redacted>")
88            .field("api_secret", &"<redacted>")
89            .field(
90                "passphrase",
91                &self.passphrase.as_ref().map(|_| "<redacted>"),
92            )
93            .field(
94                "private_key",
95                &self.private_key.as_ref().map(|_| "<redacted>"),
96            )
97            .finish()
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn builder_attaches_optional_fields() {
107        let creds = Credentials::new("key", "secret");
108        assert!(!creds.has_passphrase());
109        assert!(!creds.has_private_key());
110
111        let creds = creds.with_passphrase("pass").with_private_key("pem");
112        assert!(creds.has_passphrase());
113        assert!(creds.has_private_key());
114    }
115
116    #[test]
117    fn validate_rejects_empty_key_or_secret() {
118        assert!(Credentials::new("key", "secret").validate().is_ok());
119        assert_eq!(
120            Credentials::new("", "secret").validate().unwrap_err(),
121            Error::InvalidCredentials("api key must not be empty")
122        );
123        assert_eq!(
124            Credentials::new("key", "").validate().unwrap_err(),
125            Error::InvalidCredentials("api secret must not be empty")
126        );
127    }
128
129    #[test]
130    fn debug_never_leaks_secret_material() {
131        let creds = Credentials::new("AK_PUBLIC_VALUE", "SK_SECRET_VALUE")
132            .with_passphrase("PASS_SECRET")
133            .with_private_key("-----BEGIN EC PRIVATE KEY-----");
134        let rendered = format!("{creds:?}");
135        assert!(!rendered.contains("AK_PUBLIC_VALUE"));
136        assert!(!rendered.contains("SK_SECRET_VALUE"));
137        assert!(!rendered.contains("PASS_SECRET"));
138        assert!(!rendered.contains("BEGIN EC PRIVATE KEY"));
139        // Presence is still observable for diagnostics.
140        assert!(rendered.contains("api_key"));
141        assert!(rendered.contains("Some(\"<redacted>\")"));
142    }
143}