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