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::fmt;
13use std::net::SocketAddr;
14
15use openmoat_core::Taint;
16use zeroize::Zeroizing;
17
18/// Rule id for a request refused for its session: no valid credential, the
19/// session's connection share used up, or its history unreadable.
20pub const RULE_SESSION: &str = "proxy-session";
21
22/// Shortest token a [`Credential`] accepts, in characters.
23pub const MIN_TOKEN_LEN: usize = 32;
24
25/// Longest session id a [`Credential`] accepts.
26const MAX_SESSION_LEN: usize = 128;
27
28/// One session's credential: its audit session id and its token.
29pub struct Credential {
30    session: String,
31    token: Zeroizing<Vec<u8>>,
32}
33
34impl fmt::Debug for Credential {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        // The token never appears in a log line or panic message.
37        f.debug_struct("Credential")
38            .field("session", &self.session)
39            .finish_non_exhaustive()
40    }
41}
42
43/// Why a [`Credential`] cannot be made.
44#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45#[non_exhaustive]
46pub enum CredentialError {
47    /// The session id is empty, too long, or holds a character other than
48    /// ASCII letters, digits, `.`, `_` and `-`.
49    #[error("a session id is 1 to 128 ASCII letters, digits, `.`, `_` or `-`")]
50    Session,
51    /// The token is shorter than [`MIN_TOKEN_LEN`] or holds such a character.
52    #[error("a session token is at least 32 ASCII letters, digits, `.`, `_` or `-`")]
53    Token,
54}
55
56/// Characters that need no escaping in a URL's userinfo or a Basic credential.
57fn plain(bytes: &[u8]) -> bool {
58    bytes
59        .iter()
60        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
61}
62
63impl Credential {
64    /// The credential of `session`, which presents `token`.
65    pub fn new(session: &str, token: &[u8]) -> Result<Self, CredentialError> {
66        if session.is_empty() || session.len() > MAX_SESSION_LEN || !plain(session.as_bytes()) {
67            return Err(CredentialError::Session);
68        }
69        if token.len() < MIN_TOKEN_LEN || !plain(token) {
70            return Err(CredentialError::Token);
71        }
72        Ok(Self {
73            session: session.to_owned(),
74            token: Zeroizing::new(token.to_vec()),
75        })
76    }
77
78    /// The audit session id.
79    #[must_use]
80    pub fn session(&self) -> &str {
81        &self.session
82    }
83
84    /// `http://<session>:<token>@<addr>`: what the session's `HTTP_PROXY` and
85    /// `HTTPS_PROXY` hold. Zeroed when dropped.
86    #[must_use]
87    pub fn proxy_url(&self, addr: SocketAddr) -> Zeroizing<String> {
88        let token = String::from_utf8_lossy(&self.token);
89        Zeroizing::new(format!("http://{}:{token}@{addr}", self.session))
90    }
91}
92
93/// What a session was exposed to, read when one of its requests is decided.
94/// The CLI reads it from the audit log; tests pass a fixed map.
95pub trait TaintLookup: Send + Sync {
96    /// The taint of `session`. An error refuses the request: without the
97    /// session's history the proxy cannot tell whether it read a secret.
98    fn taint(&self, session: &str) -> Result<Taint, String>;
99}
100
101/// Why a request's credential is refused. The reply does not say whether the
102/// session id or the token was wrong.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
104#[non_exhaustive]
105pub enum AuthError {
106    /// No `Proxy-Authorization` header.
107    #[error("this proxy serves sessions only: the request carries no session credential")]
108    Missing,
109    /// More than one header, a scheme other than `Basic`, or bad base64.
110    #[error("the session credential is malformed")]
111    Malformed,
112    /// No session has this id and token.
113    #[error("the session credential is not valid")]
114    Invalid,
115}
116
117/// Session mode: the credentials the proxy accepts and where their taint is read.
118pub struct Sessions<'a> {
119    credentials: Vec<Credential>,
120    taint: &'a dyn TaintLookup,
121}
122
123impl fmt::Debug for Sessions<'_> {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        f.debug_struct("Sessions")
126            .field("credentials", &self.credentials)
127            .finish_non_exhaustive()
128    }
129}
130
131impl<'a> Sessions<'a> {
132    /// Accept `credentials`; look each session's taint up in `taint`.
133    #[must_use]
134    pub fn new(credentials: Vec<Credential>, taint: &'a dyn TaintLookup) -> Self {
135        Self { credentials, taint }
136    }
137
138    /// The session that `headers` (every `Proxy-Authorization` value of the
139    /// request) authenticate. Every credential is compared in full, in time
140    /// that does not depend on where a guess first differs.
141    pub fn verify<T: AsRef<[u8]>>(&self, headers: &[T]) -> Result<&str, AuthError> {
142        let value = match headers {
143            [] => return Err(AuthError::Missing),
144            [one] => one.as_ref(),
145            _ => return Err(AuthError::Malformed),
146        };
147        let decoded = basic(value).ok_or(AuthError::Malformed)?;
148        let colon = decoded
149            .iter()
150            .position(|b| *b == b':')
151            .ok_or(AuthError::Malformed)?;
152        let (session, token) = (&decoded[..colon], &decoded[colon + 1..]);
153        let mut found = None;
154        for credential in &self.credentials {
155            let id = constant_time_eq(credential.session.as_bytes(), session);
156            let secret = constant_time_eq(&credential.token, token);
157            if id & secret {
158                found = Some(credential.session.as_str());
159            }
160        }
161        found.ok_or(AuthError::Invalid)
162    }
163
164    /// The taint of `session`, from the lookup.
165    pub fn taint(&self, session: &str) -> Result<Taint, String> {
166        self.taint.taint(session)
167    }
168}
169
170/// The decoded `user:password` of a `Basic` credential (RFC 7617); the scheme
171/// is matched without regard to case.
172fn basic(value: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
173    let value = value.trim_ascii();
174    let space = value.iter().position(u8::is_ascii_whitespace)?;
175    let (scheme, rest) = value.split_at(space);
176    if !scheme.eq_ignore_ascii_case(b"basic") {
177        return None;
178    }
179    base64(rest.trim_ascii())
180}
181
182/// Whether `a` and `b` are equal, in time that depends only on their lengths.
183#[must_use]
184pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
185    if a.len() != b.len() {
186        return false;
187    }
188    let diff = a
189        .iter()
190        .zip(b)
191        .fold(0u8, |acc, (x, y)| acc | std::hint::black_box(x ^ y));
192    std::hint::black_box(diff) == 0
193}
194
195/// Standard base64 (RFC 4648 ยง4), padding optional; `None` on any other byte.
196fn base64(input: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
197    let data = input
198        .strip_suffix(b"==")
199        .or_else(|| input.strip_suffix(b"="))
200        .unwrap_or(input);
201    let padded = data.len() != input.len();
202    if data.is_empty() || data.len() % 4 == 1 || (padded && !input.len().is_multiple_of(4)) {
203        return None;
204    }
205    let mut out = Zeroizing::new(Vec::with_capacity(data.len() / 4 * 3 + 2));
206    let mut acc: u32 = 0;
207    let mut bits = 0u32;
208    for &c in data {
209        let v = match c {
210            b'A'..=b'Z' => c - b'A',
211            b'a'..=b'z' => c - b'a' + 26,
212            b'0'..=b'9' => c - b'0' + 52,
213            b'+' => 62,
214            b'/' => 63,
215            _ => return None,
216        };
217        acc = (acc << 6) | u32::from(v);
218        bits += 6;
219        if bits >= 8 {
220            bits -= 8;
221            out.push(u8::try_from((acc >> bits) & 0xff).ok()?);
222            acc &= (1 << bits) - 1;
223        }
224    }
225    Some(out)
226}
227
228#[cfg(test)]
229pub(crate) mod tests;