pulseengine_mcp_auth/crypto/
hashing.rs

1//! Secure hashing for API keys
2//!
3//! This module implements secure hashing using SHA256 HMAC and salt,
4//! following best practices from the Loxone MCP implementation.
5
6use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
7use rand::RngCore;
8use sha2::{Digest, Sha256};
9use std::fmt;
10
11/// Salt for key derivation (32 bytes = 256 bits)
12#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13pub struct Salt(pub [u8; 32]);
14
15impl Default for Salt {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl Salt {
22    /// Create a new random salt
23    pub fn new() -> Self {
24        let mut salt = [0u8; 32];
25        rand::thread_rng().fill_bytes(&mut salt);
26        Salt(salt)
27    }
28
29    /// Create a salt from a base64 string
30    pub fn from_base64(s: &str) -> Result<Self, HashingError> {
31        let bytes = BASE64
32            .decode(s)
33            .map_err(|e| HashingError::InvalidSalt(format!("Invalid base64: {e}")))?;
34
35        if bytes.len() != 32 {
36            return Err(HashingError::InvalidSalt(format!(
37                "Salt must be 32 bytes, got {}",
38                bytes.len()
39            )));
40        }
41
42        let mut salt = [0u8; 32];
43        salt.copy_from_slice(&bytes);
44        Ok(Salt(salt))
45    }
46
47    /// Convert salt to base64 string
48    pub fn to_base64(&self) -> String {
49        BASE64.encode(&self.0)
50    }
51}
52
53impl fmt::Display for Salt {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        write!(f, "{}", self.to_base64())
56    }
57}
58
59/// Hashing errors
60#[derive(Debug, thiserror::Error)]
61pub enum HashingError {
62    #[error("Invalid salt: {0}")]
63    InvalidSalt(String),
64
65    #[error("Invalid hash format: {0}")]
66    InvalidHash(String),
67
68    #[error("Hash verification failed")]
69    VerificationFailed,
70}
71
72/// Generate a new random salt
73pub fn generate_salt() -> Salt {
74    Salt::new()
75}
76
77/// Hash an API key with salt using SHA256
78///
79/// This implements a similar approach to Loxone's password hashing:
80/// hash = SHA256(key + ":" + salt)
81pub fn hash_api_key(api_key: &str, salt: &Salt) -> String {
82    // Combine key and salt with separator (like Loxone's pwd_salt)
83    let salted = format!("{}:{}", api_key, salt.to_base64());
84
85    // Hash using SHA256
86    let mut hasher = Sha256::new();
87    hasher.update(salted.as_bytes());
88    let hash = hasher.finalize();
89
90    // Return as base64 (more compact than hex)
91    BASE64.encode(&hash)
92}
93
94/// Verify an API key against a stored hash
95pub fn verify_api_key(api_key: &str, stored_hash: &str, salt: &Salt) -> Result<bool, HashingError> {
96    let computed_hash = hash_api_key(api_key, salt);
97
98    // Constant-time comparison to prevent timing attacks
99    use subtle::ConstantTimeEq;
100    let stored_bytes = stored_hash.as_bytes();
101    let computed_bytes = computed_hash.as_bytes();
102
103    if stored_bytes.len() != computed_bytes.len() {
104        return Ok(false);
105    }
106
107    Ok(stored_bytes.ct_eq(computed_bytes).into())
108}
109
110/// Hash data using HMAC-SHA256 (for token generation)
111pub fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
112    use hmac::{Hmac, Mac};
113    type HmacSha256 = Hmac<Sha256>;
114
115    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take key of any size");
116    mac.update(data);
117    mac.finalize().into_bytes().to_vec()
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_salt_generation() {
126        let salt1 = generate_salt();
127        let salt2 = generate_salt();
128
129        // Salts should be different
130        assert_ne!(salt1.0, salt2.0);
131
132        // Test base64 round trip
133        let base64 = salt1.to_base64();
134        let salt1_restored = Salt::from_base64(&base64).unwrap();
135        assert_eq!(salt1, salt1_restored);
136    }
137
138    #[test]
139    fn test_api_key_hashing() {
140        let api_key = "test-api-key-12345";
141        let salt = generate_salt();
142
143        let hash1 = hash_api_key(api_key, &salt);
144        let hash2 = hash_api_key(api_key, &salt);
145
146        // Same input should produce same hash
147        assert_eq!(hash1, hash2);
148
149        // Different salt should produce different hash
150        let salt2 = generate_salt();
151        let hash3 = hash_api_key(api_key, &salt2);
152        assert_ne!(hash1, hash3);
153    }
154
155    #[test]
156    fn test_api_key_verification() {
157        let api_key = "test-api-key-12345";
158        let salt = generate_salt();
159        let hash = hash_api_key(api_key, &salt);
160
161        // Correct key should verify
162        assert!(verify_api_key(api_key, &hash, &salt).unwrap());
163
164        // Wrong key should not verify
165        assert!(!verify_api_key("wrong-key", &hash, &salt).unwrap());
166
167        // Wrong salt should not verify
168        let wrong_salt = generate_salt();
169        assert!(!verify_api_key(api_key, &hash, &wrong_salt).unwrap());
170    }
171
172    #[test]
173    fn test_hmac_sha256() {
174        let key = b"test-key";
175        let data = b"test-data";
176
177        let hmac1 = hmac_sha256(key, data);
178        let hmac2 = hmac_sha256(key, data);
179
180        // Same input should produce same HMAC
181        assert_eq!(hmac1, hmac2);
182
183        // Different key should produce different HMAC
184        let hmac3 = hmac_sha256(b"different-key", data);
185        assert_ne!(hmac1, hmac3);
186    }
187}