rush_sync_server/core/
api_key.rs1use 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 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 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 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 verify_hmac_hash(hash_b64, provided)
68 } else {
69 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 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
89pub 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 let b64_part = hashed.strip_prefix(HMAC_PREFIX).unwrap();
161 assert_eq!(b64_part.len(), 44);
162 }
163}