Skip to main content

elastik_core/
auth.rs

1//! Raw token-byte auth check. Three optional configured token values map to
2//! read, write, and approve tiers; adapters decide how bytes arrive on their
3//! wire surface.
4//!
5//! Token comparison uses a small local byte loop that avoids early exit
6//! once lengths match. UTF-8 bytes on both sides — non-ASCII passwords
7//! don't crash, and the core does not depend on any SDK/runtime language
8//! to make auth decisions.
9
10use std::{
11    fmt,
12    hint::black_box,
13    ptr,
14    sync::atomic::{fence, Ordering},
15};
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum Tier {
19    Anon,
20    Read,
21    Write,
22    Approve,
23}
24
25#[derive(Debug, Copy, Clone, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum AuthGate {
28    Read,
29    Write,
30    WriteApprove,
31    Delete,
32}
33
34#[derive(Clone)]
35pub struct Tokens {
36    pub(crate) read: Option<NonEmptyBytes>,
37    pub(crate) write: Option<NonEmptyBytes>,
38    pub(crate) approve: Option<NonEmptyBytes>,
39}
40
41/// Byte credential proof: empty and whitespace-only values cannot exist.
42///
43/// The constructor is the only way to mint this type. Callers can compare or
44/// transfer the bytes, but cannot construct a value with a struct literal.
45#[derive(Clone)]
46pub(crate) struct NonEmptyBytes(Vec<u8>);
47
48impl NonEmptyBytes {
49    pub(crate) fn new(bytes: impl Into<Vec<u8>>) -> Option<Self> {
50        let bytes = bytes.into();
51        Self::is_valid(&bytes).then_some(Self(bytes))
52    }
53
54    pub(crate) fn is_valid(bytes: &[u8]) -> bool {
55        !bytes.is_empty()
56            && std::str::from_utf8(bytes)
57                .map(|value| !value.trim().is_empty())
58                .unwrap_or(true)
59    }
60
61    pub(crate) fn as_slice(&self) -> &[u8] {
62        &self.0
63    }
64
65    pub(crate) fn into_vec(mut self) -> Vec<u8> {
66        std::mem::take(&mut self.0)
67    }
68}
69
70/// Returns true when raw token bytes can represent a configured or candidate
71/// Engine token.
72///
73/// Empty and UTF-8 whitespace-only values are invalid. Non-UTF-8 bytes are
74/// allowed so language runtimes can pass opaque credential bytes without
75/// losing information.
76pub fn is_valid_token(bytes: &[u8]) -> bool {
77    NonEmptyBytes::is_valid(bytes)
78}
79
80impl Drop for NonEmptyBytes {
81    fn drop(&mut self) {
82        wipe_vec_allocation(&mut self.0);
83    }
84}
85
86impl fmt::Debug for NonEmptyBytes {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.write_str("NonEmptyBytes(..)")
89    }
90}
91
92fn wipe_vec_allocation(bytes: &mut Vec<u8>) {
93    // These bytes are credentials. Wipe the full allocation, not only len(),
94    // because callers can hand us a Vec whose spare capacity still contains
95    // prior credential bytes after truncate/reuse. Volatile writes plus a
96    // fence make the best-effort wipe physically observable before free.
97    let ptr = bytes.as_mut_ptr();
98    for index in 0..bytes.capacity() {
99        unsafe {
100            ptr::write_volatile(ptr.add(index), 0);
101        }
102    }
103    fence(Ordering::SeqCst);
104}
105
106impl Tokens {
107    pub fn read_required(&self) -> bool {
108        self.read.is_some()
109    }
110
111    pub(crate) fn check_token_bytes(&self, candidate: &[u8]) -> Tier {
112        // Candidate credentials go through the same byte validity gate as
113        // configured tokens. Invalid values are physically unable to match.
114        if !NonEmptyBytes::is_valid(candidate) {
115            return Tier::Anon;
116        }
117        // Approve first — wins ties because it's the wider tier.
118        if let Some(t) = &self.approve {
119            if ct_eq(candidate, t.as_slice()) {
120                return Tier::Approve;
121            }
122        }
123        if let Some(t) = &self.write {
124            if ct_eq(candidate, t.as_slice()) {
125                return Tier::Write;
126            }
127        }
128        if let Some(t) = &self.read {
129            if ct_eq(candidate, t.as_slice()) {
130                return Tier::Read;
131            }
132        }
133        Tier::Anon
134    }
135}
136
137/// Equal-length byte comparison without early exit.
138///
139/// `auth.rs` deliberately keeps credential parsing, validation, comparison,
140/// and wipe-on-drop on `std` primitives only. `subtle` is already present
141/// through RustCrypto, but this small auth boundary stays locally auditable:
142/// length mismatches return early, and equal-length token bytes are compared
143/// by XOR accumulation without per-byte early return. The final `black_box`
144/// keeps the result comparison visibly tied to the accumulated byte work.
145pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
146    if a.len() != b.len() {
147        return false;
148    }
149    let mut diff: u8 = 0;
150    for (x, y) in a.iter().zip(b.iter()) {
151        diff |= x ^ y;
152    }
153    black_box(diff) == 0
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::{can_delete, can_read, can_write, test_support::test_core};
160
161    fn token(bytes: &[u8]) -> NonEmptyBytes {
162        NonEmptyBytes::new(bytes.to_vec()).unwrap()
163    }
164
165    #[test]
166    fn invalid_token_candidates_never_match() {
167        let tokens = Tokens {
168            read: Some(token(b"reader")),
169            write: Some(token(b"writer")),
170            approve: Some(token(b"approve")),
171        };
172
173        assert_eq!(tokens.check_token_bytes(b""), Tier::Anon);
174        assert_eq!(tokens.check_token_bytes(b" \t\r\n"), Tier::Anon);
175        assert_eq!(tokens.check_token_bytes("\u{2003}".as_bytes()), Tier::Anon);
176    }
177
178    #[test]
179    fn var_log_requires_approve_token() {
180        assert!(!can_write("var/log", Tier::Anon));
181        assert!(!can_write("var/log", Tier::Read));
182        assert!(!can_write("var/log", Tier::Write));
183        assert!(can_write("var/log", Tier::Approve));
184        assert!(!can_write("var/log/deletes", Tier::Anon));
185        assert!(!can_write("var/log/deletes", Tier::Read));
186        assert!(!can_write("var/log/deletes", Tier::Write));
187        assert!(can_write("var/log/deletes", Tier::Approve));
188    }
189
190    #[test]
191    fn delete_requires_approve_token() {
192        assert!(!can_delete(Tier::Anon));
193        assert!(!can_delete(Tier::Read));
194        assert!(!can_delete(Tier::Write));
195        assert!(can_delete(Tier::Approve));
196    }
197
198    #[test]
199    fn system_namespace_roots_require_approve_even_if_called_directly() {
200        for name in ["lib", "etc", "boot", "usr"] {
201            assert!(!can_write(name, Tier::Anon), "{name}");
202            assert!(!can_write(name, Tier::Read), "{name}");
203            assert!(!can_write(name, Tier::Write), "{name}");
204            assert!(can_write(name, Tier::Approve), "{name}");
205        }
206    }
207
208    #[test]
209    fn non_log_var_still_accepts_auth_token() {
210        assert!(!can_write("var/cache/rag", Tier::Anon));
211        assert!(!can_write("var/cache/rag", Tier::Read));
212        assert!(can_write("var/cache/rag", Tier::Write));
213        assert!(can_write("var/cache/rag", Tier::Approve));
214    }
215
216    #[test]
217    fn read_token_is_optional_but_gates_reads_when_set() {
218        let (mut core, dir) = test_core("read-token");
219        assert!(can_read(&core, Tier::Anon));
220
221        core.tokens.read = NonEmptyBytes::new(b"reader".to_vec());
222        assert!(!can_read(&core, Tier::Anon));
223        assert!(can_read(&core, Tier::Read));
224        assert!(can_read(&core, Tier::Write));
225        assert!(can_read(&core, Tier::Approve));
226
227        let _ = std::fs::remove_dir_all(dir);
228    }
229
230    #[test]
231    fn public_token_validity_matches_core_token_gate() {
232        assert!(is_valid_token(b"reader"));
233        assert!(is_valid_token(&[0xff, 0xfe]));
234        assert!(!is_valid_token(b""));
235        assert!(!is_valid_token(b" \t\r\n"));
236    }
237
238    #[test]
239    fn wipe_vec_allocation_clears_spare_capacity() {
240        let mut bytes = Vec::with_capacity(8);
241        bytes.extend_from_slice(b"key");
242        let ptr = bytes.as_mut_ptr();
243        let cap = bytes.capacity();
244        unsafe {
245            for index in bytes.len()..cap {
246                ptr.add(index).write(b'x');
247            }
248        }
249
250        wipe_vec_allocation(&mut bytes);
251
252        unsafe {
253            bytes.set_len(cap);
254        }
255        assert!(bytes.iter().all(|byte| *byte == 0));
256    }
257
258    #[test]
259    fn nonempty_raw_tokens_still_authenticate() {
260        let tokens = Tokens {
261            read: Some(token(b"reader")),
262            write: Some(token(b"writer")),
263            approve: Some(token(b"approve")),
264        };
265
266        assert_eq!(tokens.check_token_bytes(b"reader"), Tier::Read);
267        assert_eq!(tokens.check_token_bytes(b"writer"), Tier::Write);
268        assert_eq!(tokens.check_token_bytes(b"approve"), Tier::Approve);
269        assert_eq!(tokens.check_token_bytes(b"missing"), Tier::Anon);
270    }
271}