Skip to main content

rush_sync_server/core/
api_key.rs

1// src/core/api_key.rs — Opaque API key type with HMAC-SHA256 hashing and timing-safe comparison
2
3use base64::Engine;
4use ring::hmac;
5use std::fmt;
6
7const HMAC_PREFIX: &str = "$hmac-sha256$";
8const HMAC_KEY_VALUE: &[u8] = b"rush-sync-api-key-v1";
9
10#[derive(Clone)]
11enum ApiKeySource {
12    Empty,
13    Toml(String),
14    EnvVar(String),
15}
16
17#[derive(Clone)]
18pub struct ApiKey {
19    source: ApiKeySource,
20}
21
22impl fmt::Debug for ApiKey {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        f.write_str("ApiKey(***)")
25    }
26}
27
28impl ApiKey {
29    pub fn empty() -> Self {
30        Self {
31            source: ApiKeySource::Empty,
32        }
33    }
34
35    /// Parse a value from the TOML config file.
36    /// Recognises `$hmac-sha256$...` as a pre-hashed key, otherwise stores as plaintext.
37    pub fn from_toml(value: &str) -> Self {
38        if value.is_empty() {
39            return Self::empty();
40        }
41        Self {
42            source: ApiKeySource::Toml(value.to_string()),
43        }
44    }
45
46    /// Value comes from an environment variable — will never be written back to TOML.
47    pub fn from_env(value: &str) -> Self {
48        if value.is_empty() {
49            return Self::empty();
50        }
51        Self {
52            source: ApiKeySource::EnvVar(value.to_string()),
53        }
54    }
55
56    pub fn is_empty(&self) -> bool {
57        matches!(self.source, ApiKeySource::Empty)
58    }
59
60    /// Timing-safe verification of a provided plaintext key.
61    pub fn verify(&self, provided: &str) -> bool {
62        match &self.source {
63            ApiKeySource::Empty => false,
64            ApiKeySource::Toml(stored) | ApiKeySource::EnvVar(stored) => {
65                if let Some(hash_b64) = stored.strip_prefix(HMAC_PREFIX) {
66                    // Stored value is a hash — compute HMAC of the provided key and compare
67                    verify_hmac_hash(hash_b64, provided)
68                } else {
69                    // Stored value is plaintext — timing-safe via HMAC:
70                    // sign(stored) then verify(provided) against that tag
71                    let key = hmac::Key::new(hmac::HMAC_SHA256, HMAC_KEY_VALUE);
72                    let tag = hmac::sign(&key, stored.as_bytes());
73                    hmac::verify(&key, provided.as_bytes(), tag.as_ref()).is_ok()
74                }
75            }
76        }
77    }
78
79    /// Value to persist in TOML. Returns `""` for env-var sourced keys.
80    pub fn to_toml_value(&self) -> String {
81        match &self.source {
82            ApiKeySource::Empty => String::new(),
83            ApiKeySource::Toml(v) => v.clone(),
84            ApiKeySource::EnvVar(_) => String::new(),
85        }
86    }
87}
88
89/// Compute `$hmac-sha256$<base64>` for a plaintext API key.
90pub fn hash_api_key(plaintext: &str) -> String {
91    let key = hmac::Key::new(hmac::HMAC_SHA256, HMAC_KEY_VALUE);
92    let tag = hmac::sign(&key, plaintext.as_bytes());
93    let b64 = base64::engine::general_purpose::STANDARD.encode(tag.as_ref());
94    format!("{}{}", HMAC_PREFIX, b64)
95}
96
97fn verify_hmac_hash(hash_b64: &str, provided: &str) -> bool {
98    let Ok(expected_tag) = base64::engine::general_purpose::STANDARD.decode(hash_b64) else {
99        return false;
100    };
101    let key = hmac::Key::new(hmac::HMAC_SHA256, HMAC_KEY_VALUE);
102    hmac::verify(&key, provided.as_bytes(), &expected_tag).is_ok()
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_empty_key_never_verifies() {
111        let key = ApiKey::empty();
112        assert!(key.is_empty());
113        assert!(!key.verify("anything"));
114    }
115
116    #[test]
117    fn test_plaintext_match() {
118        let key = ApiKey::from_toml("my-secret-key");
119        assert!(key.verify("my-secret-key"));
120    }
121
122    #[test]
123    fn test_plaintext_mismatch() {
124        let key = ApiKey::from_toml("my-secret-key");
125        assert!(!key.verify("wrong-key"));
126    }
127
128    #[test]
129    fn test_hash_match() {
130        let hashed = hash_api_key("super-secret");
131        let key = ApiKey::from_toml(&hashed);
132        assert!(key.verify("super-secret"));
133    }
134
135    #[test]
136    fn test_hash_mismatch() {
137        let hashed = hash_api_key("super-secret");
138        let key = ApiKey::from_toml(&hashed);
139        assert!(!key.verify("wrong-secret"));
140    }
141
142    #[test]
143    fn test_env_to_toml_value_is_empty() {
144        let key = ApiKey::from_env("env-secret");
145        assert!(key.verify("env-secret"));
146        assert_eq!(key.to_toml_value(), "");
147    }
148
149    #[test]
150    fn test_toml_to_toml_value_roundtrip() {
151        let key = ApiKey::from_toml("stored-value");
152        assert_eq!(key.to_toml_value(), "stored-value");
153    }
154
155    #[test]
156    fn test_hash_format() {
157        let hashed = hash_api_key("test");
158        assert!(hashed.starts_with(HMAC_PREFIX));
159        // Base64 of 32-byte HMAC-SHA256 tag = 44 chars
160        let b64_part = hashed.strip_prefix(HMAC_PREFIX).unwrap();
161        assert_eq!(b64_part.len(), 44);
162    }
163}