Skip to main content

camel_auth/
native_client_store.rs

1use camel_api::CamelError;
2use std::collections::HashSet;
3use std::fmt;
4use tracing::warn;
5use zeroize::Zeroizing;
6
7/// Compare two byte slices without short-circuiting on early mismatch.
8/// Returns `true` if both slices have equal length and identical bytes.
9///
10/// The length check leaks the length of the inputs, which is acceptable
11/// because client IDs are typically fixed-length or their length is not
12/// secret. The byte-by-byte comparison runs in constant time relative to
13/// the length of the inputs.
14fn 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/// ADR-0051 credential boundary: manual-redaction
33#[derive(Clone)]
34pub enum M2mClientSecret {
35    Env { name: String },
36    Plaintext { value: Zeroizing<String> },
37}
38
39/// ADR-0051 credential boundary: manual-redaction
40struct 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}\" }}"), // allow-secret
61            M2mClientSecret::Plaintext { .. } => {
62                write!(f, "Plaintext {{ value: \"[REDACTED]\" }}") // allow-secret
63            }
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            // Client ID matched but secret didn't — no point scanning further
156            // (dedup guarantees at most one entry per client_id).
157            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        // Wrong secret for client-aaa1 should return None
224        assert!(store.lookup("client-aaa1", "wrong-secret").is_none());
225        // Right secret should succeed
226        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        // SAFETY: test-scoped env mutation used to verify env-backed secret resolution.
307        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        // SAFETY: revert test-scoped env mutation before test exits.
319        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}