use base64::{Engine, engine::general_purpose::STANDARD};
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256, Sha512};
use zeroize::ZeroizeOnDrop;
use crate::error::{ExchangeError, Result};
type HmacSha512 = Hmac<Sha512>;
#[derive(Clone, ZeroizeOnDrop)]
pub struct KrakenCredentials {
pub api_key: String,
pub api_secret_b64: String,
}
impl KrakenCredentials {
pub fn new(api_key: impl Into<String>, api_secret_b64: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
api_secret_b64: api_secret_b64.into(),
}
}
pub fn from_env() -> Result<Self> {
Ok(Self {
api_key: env("KRAKEN_API_KEY")?,
api_secret_b64: env("KRAKEN_API_SECRET")?,
})
}
}
fn env(key: &str) -> Result<String> {
std::env::var(key).map_err(|_| ExchangeError::Config(format!("{key} not set")))
}
pub fn sign_kraken_request(
uri_path: &str,
nonce: u64,
post_body: &str,
api_secret_b64: &str,
) -> Result<String> {
let mut sha = Sha256::new();
sha.update(nonce.to_string().as_bytes());
sha.update(post_body.as_bytes());
let sha_result = sha.finalize();
let key = STANDARD
.decode(api_secret_b64)
.map_err(|e| ExchangeError::Auth(format!("Kraken secret is not valid base64: {e}")))?;
let mut mac = HmacSha512::new_from_slice(&key)
.map_err(|e| ExchangeError::Auth(format!("Kraken HMAC init failed: {e}")))?;
mac.update(uri_path.as_bytes());
mac.update(&sha_result);
let result = mac.finalize().into_bytes();
Ok(STANDARD.encode(result))
}
#[must_use]
pub fn form_encode(params: &[(&str, &str)]) -> String {
if params.is_empty() {
return String::new();
}
let mut out = String::with_capacity(params.len() * 16);
for (i, (k, v)) in params.iter().enumerate() {
if i > 0 {
out.push('&');
}
out.push_str(&form_url_encode(k));
out.push('=');
out.push_str(&form_url_encode(v));
}
out
}
fn form_url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
b' ' => out.push('+'),
b => {
out.push('%');
out.push(
char::from_digit(u32::from(b) >> 4, 16)
.unwrap()
.to_ascii_uppercase(),
);
out.push(
char::from_digit(u32::from(b) & 0xF, 16)
.unwrap()
.to_ascii_uppercase(),
);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sim_secret_b64() -> String {
STANDARD.encode(b"sim-secret")
}
fn other_secret_b64() -> String {
STANDARD.encode(b"other-secret")
}
#[test]
fn sign_output_is_88_char_base64() {
let secret = sim_secret_b64();
let sig = sign_kraken_request(
"/0/private/Balance",
1_700_000_000_000,
"nonce=1700000000000",
&secret,
)
.expect("sign");
assert_eq!(sig.len(), 88);
STANDARD.decode(&sig).expect("output should be base64");
}
#[test]
fn sign_is_deterministic() {
let secret = sim_secret_b64();
let body = "nonce=1700000000000&pair=XBTUSD";
let a =
sign_kraken_request("/0/private/Balance", 1_700_000_000_000, body, &secret).unwrap();
let b =
sign_kraken_request("/0/private/Balance", 1_700_000_000_000, body, &secret).unwrap();
assert_eq!(a, b);
}
#[test]
fn sign_is_sensitive_to_every_input() {
let secret = sim_secret_b64();
let other_secret = other_secret_b64();
let base = sign_kraken_request(
"/0/private/Balance",
1_700_000_000_000,
"nonce=1700000000000",
&secret,
)
.unwrap();
let by_nonce = sign_kraken_request(
"/0/private/Balance",
1_700_000_000_001, "nonce=1700000000000",
&secret,
)
.unwrap();
assert_ne!(by_nonce, base, "nonce change must alter signature");
let by_path = sign_kraken_request(
"/0/private/OpenOrders", 1_700_000_000_000,
"nonce=1700000000000",
&secret,
)
.unwrap();
assert_ne!(by_path, base, "URI path change must alter signature");
let by_body = sign_kraken_request(
"/0/private/Balance",
1_700_000_000_000,
"nonce=1700000000000&extra=x", &secret,
)
.unwrap();
assert_ne!(by_body, base, "body change must alter signature");
let by_secret = sign_kraken_request(
"/0/private/Balance",
1_700_000_000_000,
"nonce=1700000000000",
&other_secret, )
.unwrap();
assert_ne!(by_secret, base, "secret change must alter signature");
}
#[test]
fn sign_rejects_invalid_base64_secret() {
let r = sign_kraken_request("/0/private/Balance", 1, "nonce=1", "not!valid!base64!");
assert!(matches!(r, Err(ExchangeError::Auth(_))));
}
#[test]
fn form_encode_empty_returns_empty_string() {
assert_eq!(form_encode(&[]), "");
}
#[test]
fn form_encode_joins_with_ampersand() {
let body = form_encode(&[("nonce", "123"), ("pair", "XBTUSD")]);
assert_eq!(body, "nonce=123&pair=XBTUSD");
}
#[test]
fn form_encode_uses_plus_for_space() {
let body = form_encode(&[("desc", "buy 1.0 XBTUSD")]);
assert_eq!(body, "desc=buy+1.0+XBTUSD");
}
#[test]
fn form_encode_percent_encodes_reserved_chars() {
let body = form_encode(&[("k", "a&b=c")]);
assert_eq!(body, "k=a%26b%3Dc");
}
#[test]
fn credentials_round_trip() {
let c = KrakenCredentials::new("my-key", "my-base64-secret==");
assert_eq!(c.api_key, "my-key");
assert_eq!(c.api_secret_b64, "my-base64-secret==");
}
}