1use camel_api::CamelError;
2use std::collections::HashSet;
3use std::fmt;
4use tracing::warn;
5use zeroize::Zeroizing;
6
7fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
15 if a.len() != b.len() {
16 return false;
17 }
18 let mut diff = 0u8;
19 for (x, y) in a.iter().zip(b.iter()) {
20 diff |= x ^ y;
21 }
22 diff == 0
23}
24
25pub struct M2mClient {
26 pub client_id: String,
27 pub secret: M2mClientSecret,
28 pub roles: Vec<String>,
29 pub scopes: Vec<String>,
30}
31
32#[derive(Clone)]
33pub enum M2mClientSecret {
34 Env { name: String },
35 Plaintext { value: Zeroizing<String> },
36}
37
38struct ResolvedM2mClient {
39 client_id: String,
40 secret_value: Zeroizing<String>,
41 roles: Vec<String>,
42 scopes: Vec<String>,
43}
44
45pub struct M2mClientStore {
46 clients: Vec<ResolvedM2mClient>,
47}
48
49pub struct M2mClientRef<'a> {
50 pub client_id: &'a str,
51 pub roles: &'a [String],
52 pub scopes: &'a [String],
53}
54
55impl fmt::Debug for M2mClientSecret {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 match self {
58 M2mClientSecret::Env { name } => write!(f, "Env {{ name: \"{name}\" }}"), M2mClientSecret::Plaintext { .. } => {
60 write!(f, "Plaintext {{ value: \"[REDACTED]\" }}") }
62 }
63 }
64}
65
66impl fmt::Debug for M2mClient {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 f.debug_struct("M2mClient")
69 .field("client_id", &self.client_id)
70 .field("secret", &self.secret)
71 .field("roles", &self.roles)
72 .field("scopes", &self.scopes)
73 .finish()
74 }
75}
76
77impl fmt::Debug for M2mClientStore {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 f.debug_struct("M2mClientStore")
80 .field("client_count", &self.clients.len())
81 .field("secrets", &"[REDACTED]")
82 .finish()
83 }
84}
85
86impl M2mClientStore {
87 pub fn try_new(clients: Vec<M2mClient>) -> Result<Self, CamelError> {
88 let mut seen_ids = HashSet::new();
89
90 for c in &clients {
91 if !seen_ids.insert(c.client_id.clone()) {
92 return Err(CamelError::Config(format!(
93 "duplicate client_id: '{}'",
94 c.client_id
95 )));
96 }
97 }
98
99 let mut resolved = Vec::with_capacity(clients.len());
100 for c in clients {
101 let secret_value = match &c.secret {
102 M2mClientSecret::Env { name } => {
103 let val = std::env::var(name).map_err(|_| {
104 CamelError::Config(format!("M2M client env var not set: {name}"))
105 })?;
106 if val.is_empty() {
107 return Err(CamelError::Config(format!(
108 "M2M client env var is empty: {name}"
109 )));
110 }
111 Zeroizing::new(val)
112 }
113 M2mClientSecret::Plaintext { value } => {
114 if value.is_empty() {
115 return Err(CamelError::Config(
116 "M2M client plaintext secret is empty".into(),
117 ));
118 }
119 warn!(
120 "M2M client '{}' uses plaintext secret — use env vars in production",
121 c.client_id
122 );
123 value.clone()
124 }
125 };
126 resolved.push(ResolvedM2mClient {
127 client_id: c.client_id,
128 secret_value,
129 roles: c.roles,
130 scopes: c.scopes,
131 });
132 }
133
134 Ok(Self { clients: resolved })
135 }
136
137 pub fn lookup(&self, client_id: &str, client_secret: &str) -> Option<M2mClientRef<'_>> {
138 use sha2::{Digest, Sha256};
139
140 let secret_hash = Sha256::digest(client_secret.as_bytes());
141 for c in &self.clients {
142 if !constant_time_eq(c.client_id.as_bytes(), client_id.as_bytes()) {
143 continue;
144 }
145 let stored_hash = Sha256::digest(c.secret_value.as_bytes());
146 if constant_time_eq(&secret_hash, &stored_hash) {
147 return Some(M2mClientRef {
148 client_id: &c.client_id,
149 roles: &c.roles,
150 scopes: &c.scopes,
151 });
152 }
153 break;
156 }
157 None
158 }
159
160 pub fn get(&self, client_id: &str) -> Option<M2mClientRef<'_>> {
161 self.clients
162 .iter()
163 .find(|c| constant_time_eq(c.client_id.as_bytes(), client_id.as_bytes()))
164 .map(|c| M2mClientRef {
165 client_id: &c.client_id,
166 roles: &c.roles,
167 scopes: &c.scopes,
168 })
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn constant_time_eq_same_slice_returns_true() {
178 assert!(constant_time_eq(b"hello", b"hello"));
179 assert!(constant_time_eq(b"", b""));
180 assert!(constant_time_eq(b"a", b"a"));
181 }
182
183 #[test]
184 fn constant_time_eq_different_length_returns_false() {
185 assert!(!constant_time_eq(b"hello", b"world!"));
186 assert!(!constant_time_eq(b"a", b""));
187 }
188
189 #[test]
190 fn constant_time_eq_differs_in_last_byte_returns_false() {
191 assert!(!constant_time_eq(b"client-aaa1", b"client-aaa2"));
192 assert!(!constant_time_eq(b"aaa", b"aab"));
193 }
194
195 #[test]
196 fn constant_time_eq_differs_in_first_byte_returns_false() {
197 assert!(!constant_time_eq(b"xabc", b"yabc"));
198 }
199
200 #[test]
201 fn store_lookup_wrong_secret_for_similar_client_id() {
202 let store = M2mClientStore::try_new(vec![
203 M2mClient {
204 client_id: "client-aaa1".into(),
205 secret: M2mClientSecret::Plaintext {
206 value: Zeroizing::new("secret-1".into()),
207 },
208 roles: vec![],
209 scopes: vec![],
210 },
211 M2mClient {
212 client_id: "client-aaa2".into(),
213 secret: M2mClientSecret::Plaintext {
214 value: Zeroizing::new("secret-2".into()),
215 },
216 roles: vec![],
217 scopes: vec![],
218 },
219 ])
220 .unwrap();
221 assert!(store.lookup("client-aaa1", "wrong-secret").is_none());
223 assert!(store.lookup("client-aaa1", "secret-1").is_some());
225 assert!(store.lookup("client-aaa2", "secret-2").is_some());
226 }
227
228 #[test]
229 fn store_rejects_duplicate_client_ids() {
230 let result = M2mClientStore::try_new(vec![
231 M2mClient {
232 client_id: "worker".into(),
233 secret: M2mClientSecret::Plaintext {
234 value: Zeroizing::new("secret-a".into()),
235 },
236 roles: vec!["read".into()],
237 scopes: vec!["api:read".into()],
238 },
239 M2mClient {
240 client_id: "worker".into(),
241 secret: M2mClientSecret::Plaintext {
242 value: Zeroizing::new("secret-b".into()),
243 },
244 roles: vec!["write".into()],
245 scopes: vec!["api:write".into()],
246 },
247 ]);
248 let err = result.unwrap_err();
249 assert!(format!("{err}").contains("duplicate client_id"));
250 }
251
252 #[test]
253 fn store_rejects_empty_secret() {
254 let result = M2mClientStore::try_new(vec![M2mClient {
255 client_id: "worker".into(),
256 secret: M2mClientSecret::Plaintext {
257 value: Zeroizing::new("".into()),
258 },
259 roles: vec![],
260 scopes: vec![],
261 }]);
262 let err = result.unwrap_err();
263 assert!(format!("{err}").contains("empty"));
264 }
265
266 #[test]
267 fn store_lookup_valid_client_constant_time() {
268 let store = M2mClientStore::try_new(vec![M2mClient {
269 client_id: "billing".into(),
270 secret: M2mClientSecret::Plaintext {
271 value: Zeroizing::new("secret-123".into()),
272 },
273 roles: vec!["billing".into()],
274 scopes: vec!["orders:read".into(), "orders:write".into()],
275 }])
276 .unwrap();
277 let client = store.lookup("billing", "secret-123").unwrap();
278 assert_eq!(client.client_id, "billing");
279 assert_eq!(client.roles, vec!["billing"]);
280 }
281
282 #[test]
283 fn store_lookup_wrong_secret_returns_none() {
284 let store = M2mClientStore::try_new(vec![M2mClient {
285 client_id: "billing".into(),
286 secret: M2mClientSecret::Plaintext {
287 value: Zeroizing::new("secret-123".into()),
288 },
289 roles: vec![],
290 scopes: vec![],
291 }])
292 .unwrap();
293 assert!(store.lookup("billing", "wrong").is_none());
294 }
295
296 #[test]
297 fn store_lookup_unknown_client_returns_none() {
298 let store = M2mClientStore::try_new(vec![]).unwrap();
299 assert!(store.lookup("unknown", "secret").is_none());
300 }
301
302 #[test]
303 fn store_resolves_env_secret() {
304 unsafe { std::env::set_var("TEST_M2M_SECRET", "env-secret-value") };
306 let store = M2mClientStore::try_new(vec![M2mClient {
307 client_id: "worker".into(),
308 secret: M2mClientSecret::Env {
309 name: "TEST_M2M_SECRET".into(),
310 },
311 roles: vec![],
312 scopes: vec![],
313 }])
314 .unwrap();
315 assert!(store.lookup("worker", "env-secret-value").is_some());
316 unsafe { std::env::remove_var("TEST_M2M_SECRET") };
318 }
319
320 #[test]
321 fn store_rejects_missing_env_var() {
322 let result = M2mClientStore::try_new(vec![M2mClient {
323 client_id: "worker".into(),
324 secret: M2mClientSecret::Env {
325 name: "NONEXISTENT_VAR_XYZ".into(),
326 },
327 roles: vec![],
328 scopes: vec![],
329 }]);
330 let err = result.unwrap_err();
331 assert!(format!("{err}").contains("NONEXISTENT_VAR_XYZ"));
332 }
333
334 #[test]
335 fn store_debug_redacts_secrets() {
336 let store = M2mClientStore::try_new(vec![M2mClient {
337 client_id: "worker".into(),
338 secret: M2mClientSecret::Plaintext {
339 value: Zeroizing::new("super-secret".into()),
340 },
341 roles: vec![],
342 scopes: vec![],
343 }])
344 .unwrap();
345 let debug = format!("{store:?}");
346 assert!(!debug.contains("super-secret"));
347 assert!(debug.contains("[REDACTED]"));
348 }
349}