Skip to main content

openmoat_proxy/
session.rs

1//! Per-session proxy credentials (ADR-027).
2//!
3//! In session mode every request carries a `Basic` proxy credential whose user
4//! name is the audit session id and whose password is a random token only that
5//! session's processes were given. Clients send it when their proxy URL has
6//! userinfo (`http://<session>:<token>@127.0.0.1:<port>`). A request without a
7//! valid credential is refused (`proxy-session`) before its host is decided.
8//!
9//! The session id then selects the session's taint ([`TaintLookup`]) and its
10//! share of the connections (`slots.rs`).
11
12use 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
21/// Rule id for a request refused for its session: no valid credential, the
22/// session's connection share used up, or its history unreadable.
23pub const RULE_SESSION: &str = "proxy-session";
24
25/// Shortest token a [`Credential`] accepts, in characters.
26pub const MIN_TOKEN_LEN: usize = 32;
27
28/// Longest session id a [`Credential`] accepts.
29const MAX_SESSION_LEN: usize = 128;
30
31/// One session's credential: its audit session id and its token.
32pub 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        // The token never appears in a log line or panic message.
40        f.debug_struct("Credential")
41            .field("session", &self.session)
42            .finish_non_exhaustive()
43    }
44}
45
46/// Why a [`Credential`] cannot be made.
47#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
48#[non_exhaustive]
49pub enum CredentialError {
50    /// The session id is empty, too long, or holds a character other than
51    /// ASCII letters, digits, `.`, `_` and `-`.
52    #[error("a session id is 1 to 128 ASCII letters, digits, `.`, `_` or `-`")]
53    Session,
54    /// The token is shorter than [`MIN_TOKEN_LEN`] or holds such a character.
55    #[error("a session token is at least 32 ASCII letters, digits, `.`, `_` or `-`")]
56    Token,
57}
58
59/// Characters that need no escaping in a URL's userinfo or a Basic credential.
60fn 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    /// The credential of `session`, which presents `token`.
68    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    /// The audit session id.
82    #[must_use]
83    pub fn session(&self) -> &str {
84        &self.session
85    }
86
87    /// `http://<session>:<token>@<addr>`: what the session's `HTTP_PROXY` and
88    /// `HTTPS_PROXY` hold. Zeroed when dropped.
89    #[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
96/// What a session was exposed to, read when one of its requests is decided.
97/// The CLI reads it from the audit log; tests pass a fixed map.
98pub trait TaintLookup: Send + Sync {
99    /// The taint of `session`. An error refuses the request: without the
100    /// session's history the proxy cannot tell whether it read a secret.
101    fn taint(&self, session: &str) -> Result<Taint, String>;
102
103    /// A mark that changes whenever a taint may have (the audit log's newest
104    /// event id), so a taint looked up before it changed is not used again.
105    /// `None` when there is none or it cannot be read (the audit log will not
106    /// open): the taint is then looked up again every time, since a new event
107    /// could not be seen.
108    fn version(&self) -> Option<i64> {
109        None
110    }
111}
112
113/// Why a request's credential is refused. The reply does not say whether the
114/// session id or the token was wrong.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
116#[non_exhaustive]
117pub enum AuthError {
118    /// No `Proxy-Authorization` header.
119    #[error("this proxy serves sessions only: the request carries no session credential")]
120    Missing,
121    /// More than one header, a scheme other than `Basic`, or bad base64.
122    #[error("the session credential is malformed")]
123    Malformed,
124    /// No session has this id and token.
125    #[error("the session credential is not valid")]
126    Invalid,
127}
128
129/// A taint as looked up: when, at which [`TaintLookup::version`], and what.
130type Looked = (Instant, Option<i64>, Taint);
131
132/// Session mode: the credentials the proxy accepts and where their taint is read.
133pub struct Sessions<'a> {
134    credentials: Vec<Credential>,
135    taint: &'a dyn TaintLookup,
136    /// The last taint looked up per session, when, and at which
137    /// [`TaintLookup::version`]: what the open connections are checked against
138    /// again ([`Sessions::taint_within`]).
139    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    /// Accept `credentials`; look each session's taint up in `taint`.
152    #[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    /// The session that `headers` (every `Proxy-Authorization` value of the
162    /// request) authenticate. Every credential is compared in full, in time
163    /// that does not depend on where a guess first differs.
164    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    /// The taint of `session`, from the lookup.
188    pub fn taint(&self, session: &str) -> Result<Taint, String> {
189        // the mark before the lookup: an event recorded during it changes it
190        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    /// The taint of `session` as looked up at most `max_age` ago and with no
198    /// event recorded since ([`TaintLookup::version`]), else looked up again;
199    /// always looked up again while there is no version to compare.
200    /// Each open connection of a session is checked against its taint every so
201    /// often; this keeps that to one lookup per new event, at most one per
202    /// `max_age`, for the whole session. A new connection uses [`Self::taint`].
203    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
219/// The decoded `user:password` of a `Basic` credential (RFC 7617); the scheme
220/// is matched without regard to case.
221fn 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/// Whether `a` and `b` are equal, in time that depends only on their lengths.
232#[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
244/// Standard base64 (RFC 4648 ยง4), padding optional; `None` on any other byte.
245fn 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;