#![allow(unused_assignments)]
use std::fmt::Debug;
use aws_lc_rs::hmac;
use base64::prelude::*;
use nautilus_core::{env::get_or_env_var_opt, string::secret::REDACTED};
use zeroize::ZeroizeOnDrop;
#[must_use]
pub fn credential_env_vars() -> (&'static str, &'static str, &'static str) {
("OKX_API_KEY", "OKX_API_SECRET", "OKX_API_PASSPHRASE")
}
#[derive(Clone, ZeroizeOnDrop)]
pub struct Credential {
api_key: Box<str>,
api_passphrase: Box<str>,
api_secret: Box<[u8]>,
}
impl Debug for Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct(stringify!(Credential))
.field("api_key", &self.api_key)
.field("api_passphrase", &REDACTED)
.field("api_secret", &REDACTED)
.finish()
}
}
impl Credential {
#[must_use]
pub fn new(api_key: String, api_secret: String, api_passphrase: String) -> Self {
Self {
api_key: api_key.into_boxed_str(),
api_passphrase: api_passphrase.into_boxed_str(),
api_secret: api_secret.into_bytes().into_boxed_slice(),
}
}
#[must_use]
pub fn resolve(
api_key: Option<String>,
api_secret: Option<String>,
api_passphrase: Option<String>,
) -> Option<Self> {
let (key_var, secret_var, passphrase_var) = credential_env_vars();
let key = get_or_env_var_opt(api_key, key_var);
let secret = get_or_env_var_opt(api_secret, secret_var);
let passphrase = get_or_env_var_opt(api_passphrase, passphrase_var);
match (key, secret, passphrase) {
(Some(k), Some(s), Some(p)) => Some(Self::new(k, s, p)),
_ => None,
}
}
#[must_use]
pub fn api_key(&self) -> &str {
&self.api_key
}
#[must_use]
pub fn api_passphrase(&self) -> &str {
&self.api_passphrase
}
pub fn sign(&self, timestamp: &str, method: &str, endpoint: &str, body: &str) -> String {
self.sign_bytes(timestamp, method, endpoint, Some(body.as_bytes()))
}
pub fn sign_bytes(
&self,
timestamp: &str,
method: &str,
endpoint: &str,
body: Option<&[u8]>,
) -> String {
let mut message = Vec::with_capacity(
timestamp.len() + method.len() + endpoint.len() + body.map_or(0, |b| b.len()),
);
message.extend_from_slice(timestamp.as_bytes());
message.extend_from_slice(method.as_bytes());
message.extend_from_slice(endpoint.as_bytes());
if let Some(b) = body {
message.extend_from_slice(b);
}
let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
let tag = hmac::sign(&key, &message);
BASE64_STANDARD.encode(tag.as_ref())
}
#[must_use]
pub fn api_key_masked(&self) -> String {
nautilus_core::string::secret::mask_api_key(&self.api_key)
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
const API_KEY: &str = "985d5b66-57ce-40fb-b714-afc0b9787083";
const API_SECRET: &str = "chNOOS4KvNXR_Xq4k4c9qsfoKWvnDecLATCRlcBwyKDYnWgO";
const API_PASSPHRASE: &str = "1234567890";
#[rstest]
fn test_simple_get() {
let credential = Credential::new(
API_KEY.to_string(),
API_SECRET.to_string(),
API_PASSPHRASE.to_string(),
);
let signature = credential.sign(
"2020-12-08T09:08:57.715Z",
"GET",
"/api/v5/account/balance",
"",
);
assert_eq!(signature, "PJ61e1nb2F2Qd7D8SPiaIcx2gjdELc+o0ygzre9z33k=");
}
#[rstest]
fn test_get_with_query_params() {
let credential = Credential::new(
API_KEY.to_string(),
API_SECRET.to_string(),
API_PASSPHRASE.to_string(),
);
let signature = credential.sign(
"2020-12-08T09:08:57.715Z",
"GET",
"/api/v5/account/balance?ccy=BTC",
"",
);
assert!(!signature.is_empty());
BASE64_STANDARD.decode(&signature).unwrap();
let expected_message = "2020-12-08T09:08:57.715ZGET/api/v5/account/balance?ccy=BTC";
let key = hmac::Key::new(hmac::HMAC_SHA256, API_SECRET.as_bytes());
let tag = hmac::sign(&key, expected_message.as_bytes());
let expected_signature = BASE64_STANDARD.encode(tag.as_ref());
assert_eq!(signature, expected_signature);
}
#[rstest]
fn test_post_with_json_body() {
let credential = Credential::new(
API_KEY.to_string(),
API_SECRET.to_string(),
API_PASSPHRASE.to_string(),
);
let body = r#"{"instId":"BTC-USD-200925","tdMode":"isolated","side":"buy","ordType":"limit","px":"432.11","sz":"2"}"#;
let signature = credential.sign(
"2020-12-08T09:08:57.715Z",
"POST",
"/api/v5/trade/order",
body,
);
assert!(!signature.is_empty());
BASE64_STANDARD.decode(&signature).unwrap();
}
#[rstest]
fn test_post_algo_order() {
let credential = Credential::new(
API_KEY.to_string(),
API_SECRET.to_string(),
API_PASSPHRASE.to_string(),
);
let body = r#"[{"instId":"ETH-USDT-SWAP","tdMode":"isolated","side":"buy","ordType":"trigger","sz":"0.01","triggerPx":"3000","orderPx":"-1","triggerPxType":"last"}]"#;
let signature = credential.sign(
"2025-01-20T10:30:45.123Z",
"POST",
"/api/v5/trade/order-algo",
body,
);
assert!(!signature.is_empty());
BASE64_STANDARD.decode(&signature).unwrap();
let expected_message =
format!("2025-01-20T10:30:45.123ZPOST/api/v5/trade/order-algo{body}");
let key = hmac::Key::new(hmac::HMAC_SHA256, API_SECRET.as_bytes());
let tag = hmac::sign(&key, expected_message.as_bytes());
let expected_signature = BASE64_STANDARD.encode(tag.as_ref());
assert_eq!(signature, expected_signature);
}
#[rstest]
fn test_debug_redacts_secrets() {
let credential = Credential::new(
API_KEY.to_string(),
API_SECRET.to_string(),
API_PASSPHRASE.to_string(),
);
let dbg_out = format!("{credential:?}");
assert!(dbg_out.contains("api_secret: \"<redacted>\""));
assert!(dbg_out.contains("api_passphrase: \"<redacted>\""));
assert!(!dbg_out.contains("chNOO"));
assert!(
!dbg_out.contains(API_PASSPHRASE),
"Debug output must not contain passphrase"
);
}
#[rstest]
fn test_api_key_masked_short() {
let credential = Credential::new(
"short".to_string(),
"secret".to_string(),
"pass".to_string(),
);
assert_eq!(credential.api_key_masked(), "*****");
}
#[rstest]
fn test_api_key_masked_long() {
let credential = Credential::new(
API_KEY.to_string(),
API_SECRET.to_string(),
API_PASSPHRASE.to_string(),
);
assert_eq!(credential.api_key_masked(), "985d...7083");
}
#[rstest]
fn test_resolve_with_all_args() {
let result = Credential::resolve(
Some("my_key".to_string()),
Some("my_secret".to_string()),
Some("my_pass".to_string()),
);
assert!(result.is_some());
assert_eq!(result.unwrap().api_key(), "my_key");
}
#[rstest]
fn test_resolve_with_no_args_no_env() {
let (key_var, secret_var, passphrase_var) = credential_env_vars();
if std::env::var(key_var).is_ok()
|| std::env::var(secret_var).is_ok()
|| std::env::var(passphrase_var).is_ok()
{
return;
}
let result = Credential::resolve(None, None, None);
assert!(result.is_none());
}
#[rstest]
fn test_resolve_with_partial_args_returns_none() {
let (_, _, passphrase_var) = credential_env_vars();
if std::env::var(passphrase_var).is_ok() {
return;
}
let result = Credential::resolve(
Some("my_key".to_string()),
Some("my_secret".to_string()),
None,
);
assert!(result.is_none());
}
}