Skip to main content

openmoat_proxy/
session.rs

1//! Per-session proxy credentials (ADR-027, ADR-044).
2//!
3//! In session mode every request carries a `Basic` proxy credential whose user
4//! name names the audit session and whose password only that session's
5//! processes were given. Clients send it when their proxy URL has userinfo
6//! (`http://<user>:<password>@127.0.0.1:<port>`). A request without a valid
7//! credential is refused (`proxy-session`) before its host is decided.
8//!
9//! Two kinds of credential are accepted:
10//!
11//! - [`Credential`]s the proxy holds: `moat run` makes one for its run.
12//! - Credentials a [`Verify`] accepts: the standalone `moat proxy` checks the
13//!   ones the agent's session-start hook issued, which it never saw (ADR-044).
14//!
15//! The session id then selects the session's taint ([`TaintLookup`]) and its
16//! share of the connections (`slots.rs`).
17
18use 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
27/// Rule id for a request refused for its session: no valid credential, the
28/// session's connection share used up, or its history unreadable.
29pub const RULE_SESSION: &str = "proxy-session";
30
31/// Shortest token a [`Credential`] accepts, in characters.
32pub const MIN_TOKEN_LEN: usize = 32;
33
34/// Longest session id a [`Credential`] accepts.
35const MAX_SESSION_LEN: usize = 128;
36
37/// One session's credential: its audit session id and its token.
38pub 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        // The token never appears in a log line or panic message.
46        f.debug_struct("Credential")
47            .field("session", &self.session)
48            .finish_non_exhaustive()
49    }
50}
51
52/// Why a [`Credential`] cannot be made.
53#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
54#[non_exhaustive]
55pub enum CredentialError {
56    /// The session id is empty, too long, or holds a character other than
57    /// ASCII letters, digits, `.`, `_` and `-`.
58    #[error("a session id is 1 to 128 ASCII letters, digits, `.`, `_` or `-`")]
59    Session,
60    /// The token is shorter than [`MIN_TOKEN_LEN`] or holds such a character.
61    #[error("a session token is at least 32 ASCII letters, digits, `.`, `_` or `-`")]
62    Token,
63}
64
65/// Characters that need no escaping in a URL's userinfo or a Basic credential.
66fn 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    /// The credential of `session`, which presents `token`.
74    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    /// The audit session id.
88    #[must_use]
89    pub fn session(&self) -> &str {
90        &self.session
91    }
92
93    /// `http://<session>:<token>@<addr>`: what the session's `HTTP_PROXY` and
94    /// `HTTPS_PROXY` hold. Zeroed when dropped.
95    #[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
102/// Checks credentials the proxy does not hold: those the standalone proxy's
103/// sessions were issued after it started (ADR-044). The CLI implements it.
104pub trait Verify: Send + Sync {
105    /// The audit session that `user` and `password` authenticate, or `None`.
106    /// The comparison must take time that does not depend on where a guess
107    /// first differs from a valid credential.
108    fn verify(&self, user: &[u8], password: &[u8]) -> Option<String>;
109}
110
111/// What a session was exposed to, read when one of its requests is decided.
112/// The CLI reads it from the audit log; tests pass a fixed map.
113pub trait TaintLookup: Send + Sync {
114    /// The taint of `session`. An error refuses the request: without the
115    /// session's history the proxy cannot tell whether it read a secret.
116    fn taint(&self, session: &str) -> Result<Taint, String>;
117
118    /// A mark that changes whenever a taint may have (the audit log's newest
119    /// event id), so a taint looked up before it changed is not used again.
120    /// `None` when there is none or it cannot be read (the audit log will not
121    /// open): the taint is then looked up again every time, since a new event
122    /// could not be seen.
123    fn version(&self) -> Option<i64> {
124        None
125    }
126}
127
128/// Why a request's credential is refused. The reply does not say whether the
129/// session id or the token was wrong.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
131#[non_exhaustive]
132pub enum AuthError {
133    /// No `Proxy-Authorization` header.
134    #[error("this proxy serves sessions only: the request carries no session credential")]
135    Missing,
136    /// More than one header, a scheme other than `Basic`, or bad base64.
137    #[error("the session credential is malformed")]
138    Malformed,
139    /// No session has this id and token.
140    #[error("the session credential is not valid")]
141    Invalid,
142}
143
144/// A taint as looked up: when, at which [`TaintLookup::version`], and what.
145type Looked = (Instant, Option<i64>, Taint);
146
147/// How many sessions' recent taint is kept before idle ones are forgotten.
148const MAX_RECENT: usize = 1024;
149
150/// A recent taint not used for this long is forgotten once [`MAX_RECENT`] is reached.
151const RECENT_IDLE: Duration = Duration::from_mins(10);
152
153/// Session mode: the credentials the proxy accepts and where their taint is read.
154pub struct Sessions<'a> {
155    credentials: Vec<Credential>,
156    issued: Option<&'a dyn Verify>,
157    /// Serve a request that carries no credential at all, with no session
158    /// (`sandbox.proxy_serve_without_credential`, ADR-044). A wrong or
159    /// malformed credential is refused either way.
160    without_credential: bool,
161    taint: &'a dyn TaintLookup,
162    /// The last taint looked up per session, when, and at which
163    /// [`TaintLookup::version`]: what the open connections are checked against
164    /// again ([`Sessions::taint_within`]).
165    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    /// Accept `credentials`; look each session's taint up in `taint`.
180    #[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    /// Accept the credentials `issued` verifies; look each session's taint up
192    /// in `taint` (the standalone proxy, ADR-044).
193    #[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    /// Also serve requests that carry no credential, with no session: no
202    /// taint applies to them, and they share one holder's half of the
203    /// connections. A wrong or malformed credential is still refused.
204    #[must_use]
205    pub fn serving_without_credential(mut self) -> Self {
206        self.without_credential = true;
207        self
208    }
209
210    /// Whether a request without any credential is served.
211    #[must_use]
212    pub fn serves_without_credential(&self) -> bool {
213        self.without_credential
214    }
215
216    /// The session that `headers` (every `Proxy-Authorization` value of the
217    /// request) authenticate. Every credential is compared in full, in time
218    /// that does not depend on where a guess first differs.
219    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    /// The taint of `session`, from the lookup.
248    pub fn taint(&self, session: &str) -> Result<Taint, String> {
249        // the mark before the lookup: an event recorded during it changes it
250        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        // A standalone proxy serves session after session for days: forget
254        // the ones not looked up for a while, which only costs a lookup.
255        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    /// The taint of `session` as looked up at most `max_age` ago and with no
263    /// event recorded since ([`TaintLookup::version`]), else looked up again;
264    /// always looked up again while there is no version to compare.
265    /// Each open connection of a session is checked against its taint every so
266    /// often; this keeps that to one lookup per new event, at most one per
267    /// `max_age`, for the whole session. A new connection uses [`Self::taint`].
268    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
284/// The decoded `user:password` of a `Basic` credential (RFC 7617); the scheme
285/// is matched without regard to case.
286fn 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/// Whether `a` and `b` are equal, in time that depends only on their lengths.
297#[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
309/// Standard base64 (RFC 4648 ยง4), padding optional; `None` on any other byte.
310fn 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;