Skip to main content

openmoat_proxy/
broker.rs

1//! The secrets broker (ADR-020): values the proxy holds for the policy's
2//! `secrets:` list, and where they may go.
3//!
4//! A brokered secret's placeholder and value may be sent to its own host only.
5//! A plain-HTTP request to that host (`plain_http`), or one read inside a
6//! terminated tunnel (`tls: terminate`, ADR-043), gets the value in the
7//! secret's header ([`Broker::inject`]); the agent never needs it. A secret
8//! with `allow` rules goes only into the requests they name; any other request
9//! that would get it is refused before anything is sent (`route.rs`). Any
10//! other request that carries the placeholder or the value, in its head or a
11//! body the proxy reads, is refused and recorded. The check matches exact bytes: it catches a token
12//! sent by mistake or by a naive script, not one that was encoded or split
13//! across requests (`docs/THREAT_MODEL.md`). What keeps a secret safe is that
14//! the agent never holds the value.
15//!
16//! Values are zeroed when dropped and never formatted: [`Debug`] and every
17//! reason this module writes name the secret's id and host only.
18
19use std::sync::{Arc, RwLock};
20
21use openmoat_core::{Decision, Secret, Verdict};
22use zeroize::Zeroizing;
23
24use self::value::{Slot, read};
25
26/// Rule id for a request refused because it carries a brokered secret to a
27/// host that is not the secret's own.
28pub const RULE_SECRET: &str = "proxy-secret";
29
30/// Rule id for a request that would get a secret whose value has expired and
31/// was not renewed (ADR-087): it is not sent, rather than sent with a stale
32/// value or without one.
33pub const RULE_SECRET_EXPIRED: &str = "proxy-secret-expired";
34
35/// Where a request to a secret's host travels, which decides whether the
36/// secret goes in.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub(crate) enum Channel {
39    /// Plain HTTP to this port: secrets with `plain_http` on it.
40    Plain(u16),
41    /// Read inside a tunnel the proxy terminated: secrets with `tls: terminate`.
42    Terminated,
43}
44
45/// One secret and its value.
46struct Held {
47    secret: Secret,
48    placeholder: String,
49    slot: Arc<RwLock<Slot>>,
50}
51
52impl Held {
53    /// The value to put in now; `None` once it expired unrenewed.
54    fn live(&self) -> Option<Zeroizing<String>> {
55        read(&self.slot).live().cloned()
56    }
57
58    /// Whether `bytes` hold a value of this secret, current or replaced.
59    fn value_in(&self, bytes: &[u8]) -> bool {
60        read(&self.slot)
61            .known()
62            .any(|v| contains(bytes, v.as_bytes()))
63    }
64
65    /// The length of the longest value the checks look for.
66    fn longest_value(&self) -> usize {
67        read(&self.slot).known().map(|v| v.len()).max().unwrap_or(0)
68    }
69
70    /// Whether a request to `host` over `channel` gets this secret.
71    fn injects(&self, host: &str, channel: Channel) -> bool {
72        self.secret.host == host
73            && match channel {
74                Channel::Plain(port) => {
75                    self.secret.plain_http && self.secret.plain_http_port() == port
76                }
77                Channel::Terminated => self.secret.terminates(),
78            }
79    }
80}
81
82/// The brokered secrets of one proxy run. [`Broker::default`] holds none.
83#[derive(Default)]
84pub struct Broker {
85    held: Vec<Held>,
86}
87
88impl std::fmt::Debug for Broker {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.debug_list()
91            .entries(self.held.iter().map(|h| (&h.secret.id, &h.secret.host)))
92            .finish()
93    }
94}
95
96/// A secret value the broker cannot use.
97#[derive(Debug, thiserror::Error)]
98#[non_exhaustive]
99pub enum BrokerError {
100    /// The value is empty.
101    #[error("secret `{0}` is empty")]
102    Empty(String),
103    /// The value holds a control character, which could end the header it is
104    /// put in and start another.
105    #[error("secret `{0}` contains a control character")]
106    Control(String),
107}
108
109/// Which form of a secret a request carried.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum Carried {
112    /// The placeholder the agent holds.
113    Placeholder,
114    /// The value itself.
115    Value,
116}
117
118/// A brokered secret found in a request to another host.
119#[derive(Debug, Clone, Copy)]
120pub struct Leak<'a> {
121    /// The secret's id.
122    pub id: &'a str,
123    /// The only host that may receive it.
124    pub owner: &'a str,
125    /// Placeholder or value.
126    pub carried: Carried,
127}
128
129impl Leak<'_> {
130    /// The deny recorded for a request to `host` that carried this secret.
131    #[must_use]
132    pub fn decision(&self, host: &str) -> Decision {
133        let what = match self.carried {
134            Carried::Placeholder => "placeholder",
135            Carried::Value => "value",
136        };
137        Decision::single(
138            Verdict::Deny,
139            RULE_SECRET,
140            format!(
141                "request to {host} carries the {what} of secret `{}`, which only {} may receive",
142                self.id, self.owner
143            ),
144        )
145    }
146}
147
148impl Broker {
149    /// A broker for `secrets`, each with its value. The policy has already
150    /// validated the entries; this checks the values.
151    pub fn new(secrets: Vec<(Secret, Zeroizing<String>)>) -> Result<Self, BrokerError> {
152        let mut held = Vec::with_capacity(secrets.len());
153        for (secret, value) in secrets {
154            usable(&secret.id, &value)?;
155            held.push(Held {
156                placeholder: secret.placeholder(),
157                secret,
158                slot: Slot::fixed(value),
159            });
160        }
161        Ok(Self { held })
162    }
163
164    /// The handle that renews secret `id`'s value while the proxy runs, and
165    /// gives it a deadline (ADR-087). `None` for an id the broker does not hold.
166    #[must_use]
167    pub fn rotation(&self, id: &str) -> Option<Rotation> {
168        self.held
169            .iter()
170            .find(|h| h.secret.id == id)
171            .map(|h| Rotation::new(id, &h.slot))
172    }
173
174    /// True when no secret is brokered.
175    #[must_use]
176    pub fn is_empty(&self) -> bool {
177        self.held.is_empty()
178    }
179
180    /// Whether a secret of `host` says `tls: terminate`.
181    pub(crate) fn terminates(&self, host: &str) -> bool {
182        self.held
183            .iter()
184            .any(|h| h.secret.host == host && h.secret.terminates())
185    }
186
187    /// The first secret whose placeholder or value occurs in `bytes`, sent
188    /// towards `host`, that `host` may not receive.
189    #[must_use]
190    pub fn leak(&self, host: &str, bytes: &[u8]) -> Option<Leak<'_>> {
191        self.held
192            .iter()
193            .filter(|h| h.secret.host != host)
194            .find_map(|h| {
195                let carried = if contains(bytes, h.placeholder.as_bytes()) {
196                    Carried::Placeholder
197                } else if h.value_in(bytes) {
198                    Carried::Value
199                } else {
200                    return None;
201                };
202                Some(Leak {
203                    id: &h.secret.id,
204                    owner: &h.secret.host,
205                    carried,
206                })
207            })
208    }
209
210    /// Why each secret `host` owns is not put into a plain-HTTP request to
211    /// `port`: its policy entry does not set `plain_http`, or names another
212    /// port (80 when it names none).
213    pub(crate) fn withheld(&self, host: &str, port: u16) -> Vec<String> {
214        self.held
215            .iter()
216            .filter(|h| h.secret.host == host)
217            .filter_map(|h| {
218                let id = &h.secret.id;
219                if !h.secret.plain_http {
220                    Some(format!(
221                        "secret `{id}` not injected: plain HTTP needs `plain_http: true`"
222                    ))
223                } else if h.secret.plain_http_port() != port {
224                    Some(format!(
225                        "secret `{id}` not injected: it goes to port {} only, not {port}",
226                        h.secret.plain_http_port()
227                    ))
228                } else {
229                    None
230                }
231            })
232            .collect()
233    }
234
235    /// `head` (a request head the proxy wrote, lines ending in CRLF) to
236    /// `host` over `channel`, with the secrets of `host` that channel carries
237    /// ([`Channel`]) put in: in each header a secret names, the placeholder
238    /// becomes the value; a secret whose header is absent, or holds no
239    /// placeholder, gets its own header line. `None` when there is no such
240    /// secret. The caller has checked the request against their `allow` rules
241    /// and deadlines ([`Broker::admit`]); a value that expired since is left
242    /// out, never put in.
243    pub(crate) fn inject(
244        &self,
245        host: &str,
246        channel: Channel,
247        head: &[u8],
248    ) -> Option<Zeroizing<Vec<u8>>> {
249        let owned: Vec<(&Held, Zeroizing<String>)> = self
250            .held
251            .iter()
252            .filter(|h| h.injects(host, channel))
253            .filter_map(|h| Some((h, h.live()?)))
254            .collect();
255        // Each line keeps its `\r`; only the `\n`s are split on and re-added.
256        let lines = head.strip_suffix(b"\n\r\n")?;
257        if owned.is_empty() || echoed(head) {
258            return None;
259        }
260        let mut out = Zeroizing::new(Vec::with_capacity(head.len() + 256));
261        let mut placed = vec![false; owned.len()];
262        // The first line is the request line; the rest are `Name: value`.
263        for (n, line) in lines.split(|b| *b == b'\n').enumerate() {
264            let named = header_name(line).filter(|_| n > 0).and_then(|name| {
265                owned
266                    .iter()
267                    .position(|(h, _)| name.eq_ignore_ascii_case(h.secret.header.as_bytes()))
268            });
269            match named {
270                None => out.extend_from_slice(line),
271                Some(i) if contains(line, owned[i].0.placeholder.as_bytes()) => {
272                    placed[i] = true;
273                    let (h, value) = &owned[i];
274                    replace(line, h.placeholder.as_bytes(), value.as_bytes(), &mut out);
275                }
276                // A value the client chose would reach the owner in place of
277                // the secret: it is dropped, and the secret's line added below.
278                Some(_) => continue,
279            }
280            out.extend_from_slice(b"\n");
281        }
282        for ((h, value), _) in owned.iter().zip(&placed).filter(|(_, p)| !**p) {
283            out.extend_from_slice(format!("{}: ", h.secret.header).as_bytes());
284            out.extend_from_slice(value.as_bytes());
285            out.extend_from_slice(b"\r\n");
286        }
287        out.extend_from_slice(b"\r\n");
288        Some(out)
289    }
290
291    /// Length of the longest placeholder or value: a stream watched in chunks
292    /// keeps one byte less than this from the previous chunk, so a secret
293    /// split across two reads is still found.
294    fn longest(&self) -> usize {
295        self.held
296            .iter()
297            .map(|h| h.placeholder.len().max(h.longest_value()))
298            .max()
299            .unwrap_or(0)
300    }
301}
302
303/// Refuse a value that would break the header it goes in.
304fn usable(id: &str, value: &str) -> Result<(), BrokerError> {
305    if value.is_empty() {
306        return Err(BrokerError::Empty(id.to_owned()));
307    }
308    if value.chars().any(char::is_control) {
309        return Err(BrokerError::Control(id.to_owned()));
310    }
311    Ok(())
312}
313
314/// Watches one client stream towards `host`, chunk by chunk.
315pub(crate) struct Watch<'a> {
316    broker: &'a Broker,
317    host: &'a str,
318    /// The end of the bytes seen so far, kept to find a secret split across
319    /// chunks. It may hold part of a value the client sent, so it is zeroed.
320    tail: Zeroizing<Vec<u8>>,
321}
322
323impl<'a> Watch<'a> {
324    pub(crate) fn new(broker: &'a Broker, host: &'a str) -> Self {
325        Self {
326            broker,
327            host,
328            tail: Zeroizing::new(Vec::new()),
329        }
330    }
331
332    /// Look at the next `chunk` of the stream, with the end of the previous one.
333    pub(crate) fn next(&mut self, chunk: &[u8]) -> Option<Leak<'a>> {
334        if self.broker.is_empty() {
335            return None;
336        }
337        let mut window = Zeroizing::new(Vec::with_capacity(self.tail.len() + chunk.len()));
338        window.extend_from_slice(&self.tail);
339        window.extend_from_slice(chunk);
340        let keep = self.broker.longest().saturating_sub(1).min(window.len());
341        self.tail.clear();
342        self.tail.extend_from_slice(&window[window.len() - keep..]);
343        self.broker.leak(self.host, &window)
344    }
345}
346
347/// Append `line` to `out` with every `from` replaced by `to`.
348fn replace(mut line: &[u8], from: &[u8], to: &[u8], out: &mut Vec<u8>) {
349    while let Some(at) = line.windows(from.len()).position(|w| w == from) {
350        out.extend_from_slice(&line[..at]);
351        out.extend_from_slice(to);
352        line = &line[at + from.len()..];
353    }
354    out.extend_from_slice(line);
355}
356
357/// Whether `head` is a `TRACE` (or IIS's `TRACK`): the answer is the request as
358/// the server got it, so a value put in would come back to the agent.
359/// Compared without regard to case, as some servers read methods.
360fn echoed(head: &[u8]) -> bool {
361    head.get(..6)
362        .is_some_and(|m| m.eq_ignore_ascii_case(b"TRACE ") || m.eq_ignore_ascii_case(b"TRACK "))
363}
364
365/// The name of a `Name: value` header line.
366fn header_name(line: &[u8]) -> Option<&[u8]> {
367    line.iter().position(|b| *b == b':').map(|at| &line[..at])
368}
369
370fn contains(haystack: &[u8], needle: &[u8]) -> bool {
371    !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
372}
373
374mod echo;
375mod route;
376mod value;
377
378pub(crate) use self::echo::{Echo, Holdback, value_came_back};
379pub use self::route::RULE_SECRET_ROUTE;
380pub use self::value::Rotation;
381
382#[cfg(test)]
383mod tests;