use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::auth::oauth::AuthQuery;
use crate::config::ShopifyConfig;
type HmacSha256 = Hmac<Sha256>;
#[must_use]
#[allow(clippy::missing_panics_doc)] pub fn compute_signature(message: &str, secret: &str) -> String {
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
mac.update(message.as_bytes());
let result = mac.finalize();
hex::encode(result.into_bytes())
}
#[must_use]
#[allow(clippy::missing_panics_doc)] pub fn compute_signature_base64(message: &[u8], secret: &str) -> String {
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
mac.update(message);
let result = mac.finalize();
base64::encode(result.into_bytes())
}
#[must_use]
pub fn constant_time_compare(a: &str, b: &str) -> bool {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
a_bytes.ct_eq(b_bytes).into()
}
#[must_use]
pub fn validate_hmac(query: &AuthQuery, config: &ShopifyConfig) -> bool {
let signable = query.to_signable_string();
let received_hmac = &query.hmac;
let computed = compute_signature(&signable, config.api_secret_key().as_ref());
if constant_time_compare(&computed, received_hmac) {
return true;
}
if let Some(old_secret) = config.old_api_secret_key() {
let computed_old = compute_signature(&signable, old_secret.as_ref());
if constant_time_compare(&computed_old, received_hmac) {
return true;
}
}
false
}
mod hex {
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
let bytes = bytes.as_ref();
let mut result = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
result.push(HEX_CHARS[(byte >> 4) as usize] as char);
result.push(HEX_CHARS[(byte & 0x0f) as usize] as char);
}
result
}
}
mod base64 {
const BASE64_CHARS: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
let bytes = bytes.as_ref();
let len = bytes.len();
let capacity = ((len + 2) / 3) * 4;
let mut result = String::with_capacity(capacity);
let mut i = 0;
while i + 3 <= len {
let b0 = bytes[i] as usize;
let b1 = bytes[i + 1] as usize;
let b2 = bytes[i + 2] as usize;
result.push(BASE64_CHARS[b0 >> 2] as char);
result.push(BASE64_CHARS[((b0 & 0x03) << 4) | (b1 >> 4)] as char);
result.push(BASE64_CHARS[((b1 & 0x0f) << 2) | (b2 >> 6)] as char);
result.push(BASE64_CHARS[b2 & 0x3f] as char);
i += 3;
}
let remaining = len - i;
if remaining == 1 {
let b0 = bytes[i] as usize;
result.push(BASE64_CHARS[b0 >> 2] as char);
result.push(BASE64_CHARS[(b0 & 0x03) << 4] as char);
result.push('=');
result.push('=');
} else if remaining == 2 {
let b0 = bytes[i] as usize;
let b1 = bytes[i + 1] as usize;
result.push(BASE64_CHARS[b0 >> 2] as char);
result.push(BASE64_CHARS[((b0 & 0x03) << 4) | (b1 >> 4)] as char);
result.push(BASE64_CHARS[(b1 & 0x0f) << 2] as char);
result.push('=');
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ApiKey, ApiSecretKey};
#[test]
fn test_compute_signature_produces_correct_hex() {
let sig = compute_signature("test", "secret");
assert_eq!(sig.len(), 64);
assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
assert!(sig.chars().all(|c| !c.is_ascii_uppercase()));
}
#[test]
fn test_compute_signature_matches_known_value() {
let sig = compute_signature("message", "key");
assert_eq!(
sig,
"6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"
);
}
#[test]
fn test_compute_signature_with_empty_message() {
let sig = compute_signature("", "secret");
assert_eq!(sig.len(), 64);
}
#[test]
fn test_constant_time_compare_equal_strings() {
assert!(constant_time_compare("abc123", "abc123"));
assert!(constant_time_compare("", ""));
}
#[test]
fn test_constant_time_compare_different_strings() {
assert!(!constant_time_compare("abc123", "abc124"));
assert!(!constant_time_compare("abc", "abcd"));
assert!(!constant_time_compare("ABC", "abc"));
}
#[test]
fn test_constant_time_compare_different_lengths() {
assert!(!constant_time_compare("short", "longer string"));
assert!(!constant_time_compare("a", ""));
}
#[test]
fn test_validate_hmac_succeeds_with_correct_hmac() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("test-key").unwrap())
.api_secret_key(ApiSecretKey::new("test-secret").unwrap())
.build()
.unwrap();
let mut query = AuthQuery::new(
"auth-code".to_string(),
"test-shop.myshopify.com".to_string(),
"1234567890".to_string(),
"state-value".to_string(),
"host-value".to_string(),
String::new(), );
let signable = query.to_signable_string();
let computed_hmac = compute_signature(&signable, "test-secret");
query.hmac = computed_hmac;
assert!(validate_hmac(&query, &config));
}
#[test]
fn test_validate_hmac_fails_with_incorrect_hmac() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("test-key").unwrap())
.api_secret_key(ApiSecretKey::new("test-secret").unwrap())
.build()
.unwrap();
let query = AuthQuery::new(
"auth-code".to_string(),
"test-shop.myshopify.com".to_string(),
"1234567890".to_string(),
"state-value".to_string(),
"host-value".to_string(),
"invalid-hmac".to_string(),
);
assert!(!validate_hmac(&query, &config));
}
#[test]
fn test_validate_hmac_falls_back_to_old_secret() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("test-key").unwrap())
.api_secret_key(ApiSecretKey::new("new-secret").unwrap())
.old_api_secret_key(ApiSecretKey::new("old-secret").unwrap())
.build()
.unwrap();
let mut query = AuthQuery::new(
"auth-code".to_string(),
"test-shop.myshopify.com".to_string(),
"1234567890".to_string(),
"state-value".to_string(),
"host-value".to_string(),
String::new(),
);
let signable = query.to_signable_string();
let computed_hmac = compute_signature(&signable, "old-secret");
query.hmac = computed_hmac;
assert!(validate_hmac(&query, &config));
}
#[test]
fn test_validate_hmac_fails_when_both_keys_fail() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("test-key").unwrap())
.api_secret_key(ApiSecretKey::new("secret-1").unwrap())
.old_api_secret_key(ApiSecretKey::new("secret-2").unwrap())
.build()
.unwrap();
let mut query = AuthQuery::new(
"auth-code".to_string(),
"test-shop.myshopify.com".to_string(),
"1234567890".to_string(),
"state-value".to_string(),
"host-value".to_string(),
String::new(),
);
let signable = query.to_signable_string();
let computed_hmac = compute_signature(&signable, "secret-3");
query.hmac = computed_hmac;
assert!(!validate_hmac(&query, &config));
}
#[test]
fn test_validate_hmac_prefers_primary_key() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("test-key").unwrap())
.api_secret_key(ApiSecretKey::new("primary-secret").unwrap())
.old_api_secret_key(ApiSecretKey::new("old-secret").unwrap())
.build()
.unwrap();
let mut query = AuthQuery::new(
"auth-code".to_string(),
"test-shop.myshopify.com".to_string(),
"1234567890".to_string(),
"state-value".to_string(),
"host-value".to_string(),
String::new(),
);
let signable = query.to_signable_string();
let computed_hmac = compute_signature(&signable, "primary-secret");
query.hmac = computed_hmac;
assert!(validate_hmac(&query, &config));
}
#[test]
fn test_hex_encoding() {
assert_eq!(hex::encode([0x00, 0xff, 0xab, 0xcd]), "00ffabcd");
assert_eq!(hex::encode([]), "");
assert_eq!(hex::encode([0x12, 0x34]), "1234");
}
#[test]
fn test_compute_signature_base64_produces_correct_length() {
let sig = compute_signature_base64(b"test", "secret");
assert_eq!(sig.len(), 44);
}
#[test]
fn test_compute_signature_base64_matches_known_value() {
let sig = compute_signature_base64(b"message", "key");
assert_eq!(sig, "bp7ym3X//Ft6uuUn1Y/a2y/kLnIZARl2kXNDBl9Y7Uo=");
}
#[test]
fn test_compute_signature_base64_with_empty_message() {
let sig = compute_signature_base64(b"", "secret");
assert_eq!(sig.len(), 44);
}
#[test]
fn test_compute_signature_base64_valid_characters() {
let sig = compute_signature_base64(b"test payload", "secret");
assert!(sig
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='));
}
#[test]
fn test_compute_signature_base64_with_non_utf8_bytes() {
let non_utf8_bytes: &[u8] = &[0x80, 0x81, 0x82, 0xff, 0xfe];
let sig = compute_signature_base64(non_utf8_bytes, "secret");
assert_eq!(sig.len(), 44);
assert!(sig
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='));
}
#[test]
fn test_base64_encoding() {
assert_eq!(base64::encode([]), "");
assert_eq!(base64::encode([0x66]), "Zg=="); assert_eq!(base64::encode([0x66, 0x6f]), "Zm8="); assert_eq!(base64::encode([0x66, 0x6f, 0x6f]), "Zm9v"); assert_eq!(base64::encode([0x66, 0x6f, 0x6f, 0x62]), "Zm9vYg=="); assert_eq!(base64::encode([0x66, 0x6f, 0x6f, 0x62, 0x61]), "Zm9vYmE="); assert_eq!(
base64::encode([0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72]),
"Zm9vYmFy"
); }
}