Skip to main content

elastik_core/
auth.rs

1//! Bearer + Basic auth check. Three tokens, three tiers:
2//!   ELASTIK_READ_TOKEN     -> tier "read"    (T1: reads when enabled)
3//!   ELASTIK_WRITE_TOKEN    -> tier "write"   (T2: writes /home/*)
4//!   ELASTIK_APPROVE_TOKEN  -> tier "approve" (T3: writes /lib/, /etc/)
5//!
6//! Token comparison uses a small local byte loop that avoids early exit
7//! once lengths match. UTF-8 bytes on both sides — non-ASCII passwords
8//! don't crash, and the core does not depend on any SDK/runtime language
9//! to make auth decisions.
10
11use std::{
12    fmt,
13    hint::black_box,
14    ptr,
15    sync::atomic::{fence, Ordering},
16};
17
18#[cfg(test)]
19use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
20
21#[cfg(test)]
22const MAX_AUTHORIZATION_BYTES: usize = 8 * 1024;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Tier {
26    Anon,
27    Read,
28    Write,
29    Approve,
30}
31
32#[derive(Debug, Copy, Clone, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum AuthGate {
35    Read,
36    Write,
37    WriteApprove,
38    Delete,
39}
40
41#[derive(Clone)]
42pub struct Tokens {
43    pub(crate) read: Option<NonEmptyBytes>,
44    pub(crate) write: Option<NonEmptyBytes>,
45    pub(crate) approve: Option<NonEmptyBytes>,
46}
47
48/// Byte credential proof: empty and whitespace-only values cannot exist.
49///
50/// The constructor is the only way to mint this type. Callers can compare or
51/// transfer the bytes, but cannot construct a value with a struct literal.
52#[derive(Clone)]
53pub(crate) struct NonEmptyBytes(Vec<u8>);
54
55impl NonEmptyBytes {
56    pub(crate) fn new(bytes: impl Into<Vec<u8>>) -> Option<Self> {
57        let bytes = bytes.into();
58        Self::is_valid(&bytes).then_some(Self(bytes))
59    }
60
61    pub(crate) fn is_valid(bytes: &[u8]) -> bool {
62        !bytes.is_empty()
63            && std::str::from_utf8(bytes)
64                .map(|value| !value.trim().is_empty())
65                .unwrap_or(true)
66    }
67
68    pub(crate) fn as_slice(&self) -> &[u8] {
69        &self.0
70    }
71
72    pub(crate) fn into_vec(mut self) -> Vec<u8> {
73        std::mem::take(&mut self.0)
74    }
75}
76
77impl Drop for NonEmptyBytes {
78    fn drop(&mut self) {
79        wipe_vec_allocation(&mut self.0);
80    }
81}
82
83impl fmt::Debug for NonEmptyBytes {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        f.write_str("NonEmptyBytes(..)")
86    }
87}
88
89fn wipe_vec_allocation(bytes: &mut Vec<u8>) {
90    // These bytes are credentials. Wipe the full allocation, not only len(),
91    // because callers can hand us a Vec whose spare capacity still contains
92    // prior credential bytes after truncate/reuse. Volatile writes plus a
93    // fence make the best-effort wipe physically observable before free.
94    let ptr = bytes.as_mut_ptr();
95    for index in 0..bytes.capacity() {
96        unsafe {
97            ptr::write_volatile(ptr.add(index), 0);
98        }
99    }
100    fence(Ordering::SeqCst);
101}
102
103impl Tokens {
104    /// Read tokens from env. Empty / whitespace-only values are
105    /// treated as **unset** — never as "the empty token is valid."
106    /// A `.env` with `ELASTIK_WRITE_TOKEN=` (placeholder unfilled) must not
107    /// silently grant T2 to anyone sending `Authorization: Bearer `.
108    #[cfg(test)]
109    pub fn from_env() -> Self {
110        Self {
111            read: nonempty_env("ELASTIK_READ_TOKEN"),
112            write: nonempty_env("ELASTIK_WRITE_TOKEN").or_else(|| nonempty_env("ELASTIK_TOKEN")),
113            approve: nonempty_env("ELASTIK_APPROVE_TOKEN"),
114        }
115    }
116
117    pub fn read_required(&self) -> bool {
118        self.read.is_some()
119    }
120
121    /// Resolve the request's tier from an Authorization header.
122    /// Empty / missing / unrecognized → Anon. Loopback callers may
123    /// short-circuit to Anon and let the protocol layer rule.
124    #[cfg(test)]
125    pub fn check(&self, authorization: Option<&str>) -> Tier {
126        let Some(value) = authorization else {
127            return Tier::Anon;
128        };
129        if value.len() > MAX_AUTHORIZATION_BYTES {
130            return Tier::Anon;
131        }
132        let Some((scheme, credentials)) = value.split_once(char::is_whitespace) else {
133            return Tier::Anon;
134        };
135        let credentials = credentials.trim();
136        if scheme.eq_ignore_ascii_case("Bearer") {
137            return self.check_token_bytes(credentials.as_bytes());
138        }
139        if scheme.eq_ignore_ascii_case("Basic") {
140            if let Ok(decoded) = B64.decode(credentials) {
141                if let Some(idx) = decoded.iter().position(|&b| b == b':') {
142                    return self.check_token_bytes(&decoded[idx + 1..]);
143                }
144            }
145        }
146        Tier::Anon
147    }
148
149    pub(crate) fn check_token_bytes(&self, candidate: &[u8]) -> Tier {
150        // Candidate credentials go through the same byte validity gate as
151        // configured tokens. Invalid values are physically unable to match.
152        if !NonEmptyBytes::is_valid(candidate) {
153            return Tier::Anon;
154        }
155        // Approve first — wins ties because it's the wider tier.
156        if let Some(t) = &self.approve {
157            if ct_eq(candidate, t.as_slice()) {
158                return Tier::Approve;
159            }
160        }
161        if let Some(t) = &self.write {
162            if ct_eq(candidate, t.as_slice()) {
163                return Tier::Write;
164            }
165        }
166        if let Some(t) = &self.read {
167            if ct_eq(candidate, t.as_slice()) {
168                return Tier::Read;
169            }
170        }
171        Tier::Anon
172    }
173}
174
175#[cfg(test)]
176fn nonempty_env(name: &str) -> Option<NonEmptyBytes> {
177    match std::env::var(name) {
178        Ok(s) => NonEmptyBytes::new(s.into_bytes()),
179        _ => None,
180    }
181}
182
183/// True if `name` is set in the environment but holds an empty or
184/// whitespace-only string. Used by main.rs to print a startup warning
185/// — the user almost certainly meant "disabled," and we treated it as
186/// such, but they should know their `.env` placeholder is still bare.
187#[cfg(test)]
188pub fn env_set_but_empty(name: &str) -> bool {
189    match std::env::var(name) {
190        Ok(s) => s.trim().is_empty(),
191        Err(_) => false,
192    }
193}
194
195/// Equal-length byte comparison without early exit.
196///
197/// `auth.rs` deliberately keeps credential parsing, validation, comparison,
198/// and wipe-on-drop on `std` primitives only. `subtle` is already present
199/// through RustCrypto, but this small auth boundary stays locally auditable:
200/// length mismatches return early, and equal-length token bytes are compared
201/// by XOR accumulation without per-byte early return. The final `black_box`
202/// keeps the result comparison visibly tied to the accumulated byte work.
203pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
204    if a.len() != b.len() {
205        return false;
206    }
207    let mut diff: u8 = 0;
208    for (x, y) in a.iter().zip(b.iter()) {
209        diff |= x ^ y;
210    }
211    black_box(diff) == 0
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use std::sync::{Mutex, OnceLock};
218
219    fn env_lock() -> &'static Mutex<()> {
220        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
221        LOCK.get_or_init(|| Mutex::new(()))
222    }
223
224    fn bearer(token: &str) -> String {
225        format!("{} {token}", "Bearer")
226    }
227
228    fn token(bytes: &[u8]) -> NonEmptyBytes {
229        NonEmptyBytes::new(bytes.to_vec()).unwrap()
230    }
231
232    struct EnvGuard {
233        read: Option<String>,
234        write: Option<String>,
235        legacy_write: Option<String>,
236        approve: Option<String>,
237    }
238
239    impl EnvGuard {
240        fn capture() -> Self {
241            Self {
242                read: std::env::var("ELASTIK_READ_TOKEN").ok(),
243                write: std::env::var("ELASTIK_WRITE_TOKEN").ok(),
244                legacy_write: std::env::var("ELASTIK_TOKEN").ok(),
245                approve: std::env::var("ELASTIK_APPROVE_TOKEN").ok(),
246            }
247        }
248    }
249
250    impl Drop for EnvGuard {
251        fn drop(&mut self) {
252            match &self.read {
253                Some(v) => std::env::set_var("ELASTIK_READ_TOKEN", v),
254                None => std::env::remove_var("ELASTIK_READ_TOKEN"),
255            }
256            match &self.write {
257                Some(v) => std::env::set_var("ELASTIK_WRITE_TOKEN", v),
258                None => std::env::remove_var("ELASTIK_WRITE_TOKEN"),
259            }
260            match &self.legacy_write {
261                Some(v) => std::env::set_var("ELASTIK_TOKEN", v),
262                None => std::env::remove_var("ELASTIK_TOKEN"),
263            }
264            match &self.approve {
265                Some(v) => std::env::set_var("ELASTIK_APPROVE_TOKEN", v),
266                None => std::env::remove_var("ELASTIK_APPROVE_TOKEN"),
267            }
268        }
269    }
270
271    #[test]
272    fn from_env_treats_empty_tokens_as_disabled() {
273        let _lock = env_lock().lock().unwrap();
274        let _env = EnvGuard::capture();
275        std::env::set_var("ELASTIK_READ_TOKEN", " ");
276        std::env::set_var("ELASTIK_WRITE_TOKEN", "");
277        std::env::remove_var("ELASTIK_TOKEN");
278        std::env::set_var("ELASTIK_APPROVE_TOKEN", "\u{2003}\n");
279
280        let tokens = Tokens::from_env();
281
282        assert!(tokens.read.is_none());
283        assert!(tokens.write.is_none());
284        assert!(tokens.approve.is_none());
285        assert_eq!(tokens.check(Some("Bearer ")), Tier::Anon);
286        assert_eq!(tokens.check(Some("Basic Og==")), Tier::Anon);
287        assert!(env_set_but_empty("ELASTIK_READ_TOKEN"));
288        assert!(env_set_but_empty("ELASTIK_WRITE_TOKEN"));
289        assert!(env_set_but_empty("ELASTIK_APPROVE_TOKEN"));
290    }
291
292    #[test]
293    fn legacy_elastik_token_is_a_write_token_fallback() {
294        let _lock = env_lock().lock().unwrap();
295        let _env = EnvGuard::capture();
296        std::env::remove_var("ELASTIK_WRITE_TOKEN");
297        std::env::set_var("ELASTIK_TOKEN", "legacy-writer");
298
299        let tokens = Tokens::from_env();
300
301        assert_eq!(tokens.check(Some(&bearer("legacy-writer"))), Tier::Write);
302    }
303
304    #[test]
305    fn invalid_authorization_candidates_never_match() {
306        let tokens = Tokens {
307            read: Some(token(b"reader")),
308            write: Some(token(b"writer")),
309            approve: Some(token(b"approve")),
310        };
311
312        assert_eq!(tokens.check(Some("Bearer ")), Tier::Anon);
313        assert_eq!(tokens.check(Some("Bearer \t\r\n")), Tier::Anon);
314        assert_eq!(tokens.check(Some(&bearer("\u{2003}"))), Tier::Anon);
315        assert_eq!(tokens.check(Some("Basic Og==")), Tier::Anon);
316    }
317
318    #[test]
319    fn wipe_vec_allocation_clears_spare_capacity() {
320        let mut bytes = Vec::with_capacity(8);
321        bytes.extend_from_slice(b"key");
322        let ptr = bytes.as_mut_ptr();
323        let cap = bytes.capacity();
324        unsafe {
325            for index in bytes.len()..cap {
326                ptr.add(index).write(b'x');
327            }
328        }
329
330        wipe_vec_allocation(&mut bytes);
331
332        unsafe {
333            bytes.set_len(cap);
334        }
335        assert!(bytes.iter().all(|byte| *byte == 0));
336    }
337
338    #[test]
339    fn oversized_authorization_header_is_anon() {
340        let tokens = Tokens {
341            read: Some(token(b"reader")),
342            write: Some(token(b"writer")),
343            approve: Some(token(b"approve")),
344        };
345        let header = format!("Bearer {}", "x".repeat(MAX_AUTHORIZATION_BYTES));
346
347        assert_eq!(tokens.check(Some(&header)), Tier::Anon);
348    }
349
350    #[test]
351    fn nonempty_tokens_still_authenticate() {
352        let tokens = Tokens {
353            read: Some(token(b"reader")),
354            write: Some(token(b"writer")),
355            approve: Some(token(b"approve")),
356        };
357        let basic_writer = B64.encode("user:writer");
358
359        assert_eq!(tokens.check(Some(&bearer("reader"))), Tier::Read);
360        assert_eq!(tokens.check(Some("bearer reader")), Tier::Read);
361        assert_eq!(tokens.check(Some(&bearer("writer"))), Tier::Write);
362        assert_eq!(
363            tokens.check(Some(&format!("Basic {basic_writer}"))),
364            Tier::Write
365        );
366        assert_eq!(
367            tokens.check(Some(&format!("basic {basic_writer}"))),
368            Tier::Write
369        );
370        assert_eq!(tokens.check(Some(&bearer("approve"))), Tier::Approve);
371        assert_eq!(tokens.check(Some("Bearer ")), Tier::Anon);
372    }
373}