1use std::collections::HashMap;
19use std::fmt;
20use std::net::SocketAddr;
21use std::sync::{Mutex, PoisonError};
22use std::time::{Duration, Instant};
23
24use openmoat_core::Taint;
25use zeroize::Zeroizing;
26
27pub const RULE_SESSION: &str = "proxy-session";
30
31pub const MIN_TOKEN_LEN: usize = 32;
33
34const MAX_SESSION_LEN: usize = 128;
36
37pub struct Credential {
39 session: String,
40 token: Zeroizing<Vec<u8>>,
41}
42
43impl fmt::Debug for Credential {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 f.debug_struct("Credential")
47 .field("session", &self.session)
48 .finish_non_exhaustive()
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
54#[non_exhaustive]
55pub enum CredentialError {
56 #[error("a session id is 1 to 128 ASCII letters, digits, `.`, `_` or `-`")]
59 Session,
60 #[error("a session token is at least 32 ASCII letters, digits, `.`, `_` or `-`")]
62 Token,
63}
64
65fn plain(bytes: &[u8]) -> bool {
67 bytes
68 .iter()
69 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
70}
71
72impl Credential {
73 pub fn new(session: &str, token: &[u8]) -> Result<Self, CredentialError> {
75 if session.is_empty() || session.len() > MAX_SESSION_LEN || !plain(session.as_bytes()) {
76 return Err(CredentialError::Session);
77 }
78 if token.len() < MIN_TOKEN_LEN || !plain(token) {
79 return Err(CredentialError::Token);
80 }
81 Ok(Self {
82 session: session.to_owned(),
83 token: Zeroizing::new(token.to_vec()),
84 })
85 }
86
87 #[must_use]
89 pub fn session(&self) -> &str {
90 &self.session
91 }
92
93 #[must_use]
96 pub fn proxy_url(&self, addr: SocketAddr) -> Zeroizing<String> {
97 let token = String::from_utf8_lossy(&self.token);
98 Zeroizing::new(format!("http://{}:{token}@{addr}", self.session))
99 }
100}
101
102pub trait Verify: Send + Sync {
105 fn verify(&self, user: &[u8], password: &[u8]) -> Option<String>;
109}
110
111pub trait TaintLookup: Send + Sync {
114 fn taint(&self, session: &str) -> Result<Taint, String>;
117
118 fn version(&self) -> Option<i64> {
124 None
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
131#[non_exhaustive]
132pub enum AuthError {
133 #[error("this proxy serves sessions only: the request carries no session credential")]
135 Missing,
136 #[error("the session credential is malformed")]
138 Malformed,
139 #[error("the session credential is not valid")]
141 Invalid,
142}
143
144type Looked = (Instant, Option<i64>, Taint);
146
147const MAX_RECENT: usize = 1024;
149
150const RECENT_IDLE: Duration = Duration::from_mins(10);
152
153pub struct Sessions<'a> {
155 credentials: Vec<Credential>,
156 issued: Option<&'a dyn Verify>,
157 without_credential: bool,
161 taint: &'a dyn TaintLookup,
162 recent: Mutex<HashMap<String, Looked>>,
166}
167
168impl fmt::Debug for Sessions<'_> {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 f.debug_struct("Sessions")
171 .field("credentials", &self.credentials)
172 .field("issued", &self.issued.is_some())
173 .field("without_credential", &self.without_credential)
174 .finish_non_exhaustive()
175 }
176}
177
178impl<'a> Sessions<'a> {
179 #[must_use]
181 pub fn new(credentials: Vec<Credential>, taint: &'a dyn TaintLookup) -> Self {
182 Self {
183 credentials,
184 issued: None,
185 without_credential: false,
186 taint,
187 recent: Mutex::new(HashMap::new()),
188 }
189 }
190
191 #[must_use]
194 pub fn issued(issued: &'a dyn Verify, taint: &'a dyn TaintLookup) -> Self {
195 Self {
196 issued: Some(issued),
197 ..Self::new(Vec::new(), taint)
198 }
199 }
200
201 #[must_use]
205 pub fn serving_without_credential(mut self) -> Self {
206 self.without_credential = true;
207 self
208 }
209
210 #[must_use]
212 pub fn serves_without_credential(&self) -> bool {
213 self.without_credential
214 }
215
216 pub fn verify<T: AsRef<[u8]>>(&self, headers: &[T]) -> Result<String, AuthError> {
220 let value = match headers {
221 [] => return Err(AuthError::Missing),
222 [one] => one.as_ref(),
223 _ => return Err(AuthError::Malformed),
224 };
225 let decoded = basic(value).ok_or(AuthError::Malformed)?;
226 let colon = decoded
227 .iter()
228 .position(|b| *b == b':')
229 .ok_or(AuthError::Malformed)?;
230 let (session, token) = (&decoded[..colon], &decoded[colon + 1..]);
231 let mut found = None;
232 for credential in &self.credentials {
233 let id = constant_time_eq(credential.session.as_bytes(), session);
234 let secret = constant_time_eq(&credential.token, token);
235 if id & secret {
236 found = Some(credential.session.clone());
237 }
238 }
239 if found.is_none()
240 && let Some(issued) = self.issued
241 {
242 found = issued.verify(session, token);
243 }
244 found.ok_or(AuthError::Invalid)
245 }
246
247 pub fn taint(&self, session: &str) -> Result<Taint, String> {
249 let version = self.taint.version();
251 let taint = self.taint.taint(session)?;
252 let mut recent = self.recent.lock().unwrap_or_else(PoisonError::into_inner);
253 if recent.len() >= MAX_RECENT {
256 recent.retain(|_, (at, _, _)| at.elapsed() < RECENT_IDLE);
257 }
258 recent.insert(session.to_owned(), (Instant::now(), version, taint.clone()));
259 Ok(taint)
260 }
261
262 pub fn taint_within(&self, session: &str, max_age: Duration) -> Result<Taint, String> {
269 let version = self.taint.version();
270 {
271 let recent = self.recent.lock().unwrap_or_else(PoisonError::into_inner);
272 if let Some((at, seen, taint)) = recent.get(session)
273 && at.elapsed() < max_age
274 && version.is_some()
275 && *seen == version
276 {
277 return Ok(taint.clone());
278 }
279 }
280 self.taint(session)
281 }
282}
283
284fn basic(value: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
287 let value = value.trim_ascii();
288 let space = value.iter().position(u8::is_ascii_whitespace)?;
289 let (scheme, rest) = value.split_at(space);
290 if !scheme.eq_ignore_ascii_case(b"basic") {
291 return None;
292 }
293 base64(rest.trim_ascii())
294}
295
296#[must_use]
298pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
299 if a.len() != b.len() {
300 return false;
301 }
302 let diff = a
303 .iter()
304 .zip(b)
305 .fold(0u8, |acc, (x, y)| acc | std::hint::black_box(x ^ y));
306 std::hint::black_box(diff) == 0
307}
308
309fn base64(input: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
311 let data = input
312 .strip_suffix(b"==")
313 .or_else(|| input.strip_suffix(b"="))
314 .unwrap_or(input);
315 let padded = data.len() != input.len();
316 if data.is_empty() || data.len() % 4 == 1 || (padded && !input.len().is_multiple_of(4)) {
317 return None;
318 }
319 let mut out = Zeroizing::new(Vec::with_capacity(data.len() / 4 * 3 + 2));
320 let mut acc: u32 = 0;
321 let mut bits = 0u32;
322 for &c in data {
323 let v = match c {
324 b'A'..=b'Z' => c - b'A',
325 b'a'..=b'z' => c - b'a' + 26,
326 b'0'..=b'9' => c - b'0' + 52,
327 b'+' => 62,
328 b'/' => 63,
329 _ => return None,
330 };
331 acc = (acc << 6) | u32::from(v);
332 bits += 6;
333 if bits >= 8 {
334 bits -= 8;
335 out.push(u8::try_from((acc >> bits) & 0xff).ok()?);
336 acc &= (1 << bits) - 1;
337 }
338 }
339 Some(out)
340}
341
342#[cfg(test)]
343pub(crate) mod tests;