openmoat_proxy/
session.rs1use std::collections::HashMap;
13use std::fmt;
14use std::net::SocketAddr;
15use std::sync::{Mutex, PoisonError};
16use std::time::{Duration, Instant};
17
18use openmoat_core::Taint;
19use zeroize::Zeroizing;
20
21pub const RULE_SESSION: &str = "proxy-session";
24
25pub const MIN_TOKEN_LEN: usize = 32;
27
28const MAX_SESSION_LEN: usize = 128;
30
31pub struct Credential {
33 session: String,
34 token: Zeroizing<Vec<u8>>,
35}
36
37impl fmt::Debug for Credential {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 f.debug_struct("Credential")
41 .field("session", &self.session)
42 .finish_non_exhaustive()
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
48#[non_exhaustive]
49pub enum CredentialError {
50 #[error("a session id is 1 to 128 ASCII letters, digits, `.`, `_` or `-`")]
53 Session,
54 #[error("a session token is at least 32 ASCII letters, digits, `.`, `_` or `-`")]
56 Token,
57}
58
59fn plain(bytes: &[u8]) -> bool {
61 bytes
62 .iter()
63 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
64}
65
66impl Credential {
67 pub fn new(session: &str, token: &[u8]) -> Result<Self, CredentialError> {
69 if session.is_empty() || session.len() > MAX_SESSION_LEN || !plain(session.as_bytes()) {
70 return Err(CredentialError::Session);
71 }
72 if token.len() < MIN_TOKEN_LEN || !plain(token) {
73 return Err(CredentialError::Token);
74 }
75 Ok(Self {
76 session: session.to_owned(),
77 token: Zeroizing::new(token.to_vec()),
78 })
79 }
80
81 #[must_use]
83 pub fn session(&self) -> &str {
84 &self.session
85 }
86
87 #[must_use]
90 pub fn proxy_url(&self, addr: SocketAddr) -> Zeroizing<String> {
91 let token = String::from_utf8_lossy(&self.token);
92 Zeroizing::new(format!("http://{}:{token}@{addr}", self.session))
93 }
94}
95
96pub trait TaintLookup: Send + Sync {
99 fn taint(&self, session: &str) -> Result<Taint, String>;
102
103 fn version(&self) -> Option<i64> {
109 None
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
116#[non_exhaustive]
117pub enum AuthError {
118 #[error("this proxy serves sessions only: the request carries no session credential")]
120 Missing,
121 #[error("the session credential is malformed")]
123 Malformed,
124 #[error("the session credential is not valid")]
126 Invalid,
127}
128
129type Looked = (Instant, Option<i64>, Taint);
131
132pub struct Sessions<'a> {
134 credentials: Vec<Credential>,
135 taint: &'a dyn TaintLookup,
136 recent: Mutex<HashMap<String, Looked>>,
140}
141
142impl fmt::Debug for Sessions<'_> {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 f.debug_struct("Sessions")
145 .field("credentials", &self.credentials)
146 .finish_non_exhaustive()
147 }
148}
149
150impl<'a> Sessions<'a> {
151 #[must_use]
153 pub fn new(credentials: Vec<Credential>, taint: &'a dyn TaintLookup) -> Self {
154 Self {
155 credentials,
156 taint,
157 recent: Mutex::new(HashMap::new()),
158 }
159 }
160
161 pub fn verify<T: AsRef<[u8]>>(&self, headers: &[T]) -> Result<&str, AuthError> {
165 let value = match headers {
166 [] => return Err(AuthError::Missing),
167 [one] => one.as_ref(),
168 _ => return Err(AuthError::Malformed),
169 };
170 let decoded = basic(value).ok_or(AuthError::Malformed)?;
171 let colon = decoded
172 .iter()
173 .position(|b| *b == b':')
174 .ok_or(AuthError::Malformed)?;
175 let (session, token) = (&decoded[..colon], &decoded[colon + 1..]);
176 let mut found = None;
177 for credential in &self.credentials {
178 let id = constant_time_eq(credential.session.as_bytes(), session);
179 let secret = constant_time_eq(&credential.token, token);
180 if id & secret {
181 found = Some(credential.session.as_str());
182 }
183 }
184 found.ok_or(AuthError::Invalid)
185 }
186
187 pub fn taint(&self, session: &str) -> Result<Taint, String> {
189 let version = self.taint.version();
191 let taint = self.taint.taint(session)?;
192 let mut recent = self.recent.lock().unwrap_or_else(PoisonError::into_inner);
193 recent.insert(session.to_owned(), (Instant::now(), version, taint.clone()));
194 Ok(taint)
195 }
196
197 pub fn taint_within(&self, session: &str, max_age: Duration) -> Result<Taint, String> {
204 let version = self.taint.version();
205 {
206 let recent = self.recent.lock().unwrap_or_else(PoisonError::into_inner);
207 if let Some((at, seen, taint)) = recent.get(session)
208 && at.elapsed() < max_age
209 && version.is_some()
210 && *seen == version
211 {
212 return Ok(taint.clone());
213 }
214 }
215 self.taint(session)
216 }
217}
218
219fn basic(value: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
222 let value = value.trim_ascii();
223 let space = value.iter().position(u8::is_ascii_whitespace)?;
224 let (scheme, rest) = value.split_at(space);
225 if !scheme.eq_ignore_ascii_case(b"basic") {
226 return None;
227 }
228 base64(rest.trim_ascii())
229}
230
231#[must_use]
233pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
234 if a.len() != b.len() {
235 return false;
236 }
237 let diff = a
238 .iter()
239 .zip(b)
240 .fold(0u8, |acc, (x, y)| acc | std::hint::black_box(x ^ y));
241 std::hint::black_box(diff) == 0
242}
243
244fn base64(input: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
246 let data = input
247 .strip_suffix(b"==")
248 .or_else(|| input.strip_suffix(b"="))
249 .unwrap_or(input);
250 let padded = data.len() != input.len();
251 if data.is_empty() || data.len() % 4 == 1 || (padded && !input.len().is_multiple_of(4)) {
252 return None;
253 }
254 let mut out = Zeroizing::new(Vec::with_capacity(data.len() / 4 * 3 + 2));
255 let mut acc: u32 = 0;
256 let mut bits = 0u32;
257 for &c in data {
258 let v = match c {
259 b'A'..=b'Z' => c - b'A',
260 b'a'..=b'z' => c - b'a' + 26,
261 b'0'..=b'9' => c - b'0' + 52,
262 b'+' => 62,
263 b'/' => 63,
264 _ => return None,
265 };
266 acc = (acc << 6) | u32::from(v);
267 bits += 6;
268 if bits >= 8 {
269 bits -= 8;
270 out.push(u8::try_from((acc >> bits) & 0xff).ok()?);
271 acc &= (1 << bits) - 1;
272 }
273 }
274 Some(out)
275}
276
277#[cfg(test)]
278pub(crate) mod tests;