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