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