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 gets the value in the secret's header
6//! ([`Broker::inject`]); the agent never needs it. Any other request that carries either, in its head or its plain-HTTP body,
7//! is refused and recorded. The check matches exact bytes: it catches a token
8//! sent by mistake or by a naive script, not one that was encoded or split
9//! across requests (`docs/THREAT_MODEL.md`). What keeps a secret safe is that
10//! the agent never holds the value.
11//!
12//! Values are zeroed when dropped and never formatted: [`Debug`] and every
13//! reason this module writes name the secret's id and host only.
14
15use openmoat_core::{Decision, Secret, Verdict};
16use zeroize::Zeroizing;
17
18/// Rule id for a request refused because it carries a brokered secret to a
19/// host that is not the secret's own.
20pub const RULE_SECRET: &str = "proxy-secret";
21
22/// One secret and its value.
23struct Held {
24    secret: Secret,
25    placeholder: String,
26    value: Zeroizing<String>,
27}
28
29/// The brokered secrets of one proxy run. [`Broker::default`] holds none.
30#[derive(Default)]
31pub struct Broker {
32    held: Vec<Held>,
33}
34
35impl std::fmt::Debug for Broker {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_list()
38            .entries(self.held.iter().map(|h| (&h.secret.id, &h.secret.host)))
39            .finish()
40    }
41}
42
43/// A secret value the broker cannot use.
44#[derive(Debug, thiserror::Error)]
45#[non_exhaustive]
46pub enum BrokerError {
47    /// The value is empty.
48    #[error("secret `{0}` is empty")]
49    Empty(String),
50    /// The value holds a control character, which could end the header it is
51    /// put in and start another.
52    #[error("secret `{0}` contains a control character")]
53    Control(String),
54}
55
56/// Which form of a secret a request carried.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Carried {
59    /// The placeholder the agent holds.
60    Placeholder,
61    /// The value itself.
62    Value,
63}
64
65/// A brokered secret found in a request to another host.
66#[derive(Debug, Clone, Copy)]
67pub struct Leak<'a> {
68    /// The secret's id.
69    pub id: &'a str,
70    /// The only host that may receive it.
71    pub owner: &'a str,
72    /// Placeholder or value.
73    pub carried: Carried,
74}
75
76impl Leak<'_> {
77    /// The deny recorded for a request to `host` that carried this secret.
78    #[must_use]
79    pub fn decision(&self, host: &str) -> Decision {
80        let what = match self.carried {
81            Carried::Placeholder => "placeholder",
82            Carried::Value => "value",
83        };
84        Decision::single(
85            Verdict::Deny,
86            RULE_SECRET,
87            format!(
88                "request to {host} carries the {what} of secret `{}`, which only {} may receive",
89                self.id, self.owner
90            ),
91        )
92    }
93}
94
95impl Broker {
96    /// A broker for `secrets`, each with its value. The policy has already
97    /// validated the entries; this checks the values.
98    pub fn new(secrets: Vec<(Secret, Zeroizing<String>)>) -> Result<Self, BrokerError> {
99        let mut held = Vec::with_capacity(secrets.len());
100        for (secret, value) in secrets {
101            if value.is_empty() {
102                return Err(BrokerError::Empty(secret.id));
103            }
104            if value.chars().any(char::is_control) {
105                return Err(BrokerError::Control(secret.id));
106            }
107            held.push(Held {
108                placeholder: secret.placeholder(),
109                secret,
110                value,
111            });
112        }
113        Ok(Self { held })
114    }
115
116    /// True when no secret is brokered.
117    #[must_use]
118    pub fn is_empty(&self) -> bool {
119        self.held.is_empty()
120    }
121
122    /// The first secret whose placeholder or value occurs in `bytes`, sent
123    /// towards `host`, that `host` may not receive.
124    #[must_use]
125    pub fn leak(&self, host: &str, bytes: &[u8]) -> Option<Leak<'_>> {
126        self.held
127            .iter()
128            .filter(|h| h.secret.host != host)
129            .find_map(|h| {
130                let carried = if contains(bytes, h.placeholder.as_bytes()) {
131                    Carried::Placeholder
132                } else if contains(bytes, h.value.as_bytes()) {
133                    Carried::Value
134                } else {
135                    return None;
136                };
137                Some(Leak {
138                    id: &h.secret.id,
139                    owner: &h.secret.host,
140                    carried,
141                })
142            })
143    }
144
145    /// Ids of the secrets `host` owns that are not put into plain-HTTP
146    /// requests, because their policy entry does not set `plain_http`.
147    pub(crate) fn withheld(&self, host: &str) -> Vec<&str> {
148        self.held
149            .iter()
150            .filter(|h| h.secret.host == host && !h.secret.plain_http)
151            .map(|h| h.secret.id.as_str())
152            .collect()
153    }
154
155    /// `head` (a plain-HTTP request head the proxy wrote, lines ending in
156    /// CRLF) with the secrets of `host` that allow `plain_http` put in: in
157    /// each header a secret names, the placeholder becomes the value; a secret
158    /// whose header is absent, or holds no placeholder, gets its own header
159    /// line. `None` when there is no such secret.
160    pub(crate) fn inject(&self, host: &str, head: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
161        let owned: Vec<&Held> = self
162            .held
163            .iter()
164            .filter(|h| h.secret.host == host && h.secret.plain_http)
165            .collect();
166        // Each line keeps its `\r`; only the `\n`s are split on and re-added.
167        let lines = head.strip_suffix(b"\n\r\n")?;
168        if owned.is_empty() {
169            return None;
170        }
171        let mut out = Zeroizing::new(Vec::with_capacity(head.len() + 256));
172        let mut placed = vec![false; owned.len()];
173        // The first line is the request line; the rest are `Name: value`.
174        for (n, line) in lines.split(|b| *b == b'\n').enumerate() {
175            let named = header_name(line).filter(|_| n > 0).and_then(|name| {
176                owned
177                    .iter()
178                    .position(|h| name.eq_ignore_ascii_case(h.secret.header.as_bytes()))
179            });
180            match named {
181                None => out.extend_from_slice(line),
182                Some(i) if contains(line, owned[i].placeholder.as_bytes()) => {
183                    placed[i] = true;
184                    let h = owned[i];
185                    replace(line, h.placeholder.as_bytes(), h.value.as_bytes(), &mut out);
186                }
187                // A value the client chose would reach the owner in place of
188                // the secret: it is dropped, and the secret's line added below.
189                Some(_) => continue,
190            }
191            out.extend_from_slice(b"\n");
192        }
193        for (h, _) in owned.iter().zip(&placed).filter(|(_, p)| !**p) {
194            out.extend_from_slice(format!("{}: ", h.secret.header).as_bytes());
195            out.extend_from_slice(h.value.as_bytes());
196            out.extend_from_slice(b"\r\n");
197        }
198        out.extend_from_slice(b"\r\n");
199        Some(out)
200    }
201
202    /// Length of the longest placeholder or value: a stream watched in chunks
203    /// keeps one byte less than this from the previous chunk, so a secret
204    /// split across two reads is still found.
205    fn longest(&self) -> usize {
206        self.held
207            .iter()
208            .map(|h| h.placeholder.len().max(h.value.len()))
209            .max()
210            .unwrap_or(0)
211    }
212}
213
214/// Watches one client stream towards `host`, chunk by chunk.
215pub(crate) struct Watch<'a> {
216    broker: &'a Broker,
217    host: &'a str,
218    /// The end of the bytes seen so far, kept to find a secret split across
219    /// chunks. It may hold part of a value the client sent, so it is zeroed.
220    tail: Zeroizing<Vec<u8>>,
221}
222
223impl<'a> Watch<'a> {
224    pub(crate) fn new(broker: &'a Broker, host: &'a str) -> Self {
225        Self {
226            broker,
227            host,
228            tail: Zeroizing::new(Vec::new()),
229        }
230    }
231
232    /// Look at the next `chunk` of the stream, with the end of the previous one.
233    pub(crate) fn next(&mut self, chunk: &[u8]) -> Option<Leak<'a>> {
234        if self.broker.is_empty() {
235            return None;
236        }
237        let mut window = Zeroizing::new(Vec::with_capacity(self.tail.len() + chunk.len()));
238        window.extend_from_slice(&self.tail);
239        window.extend_from_slice(chunk);
240        let keep = self.broker.longest().saturating_sub(1).min(window.len());
241        self.tail.clear();
242        self.tail.extend_from_slice(&window[window.len() - keep..]);
243        self.broker.leak(self.host, &window)
244    }
245}
246
247/// Append `line` to `out` with every `from` replaced by `to`.
248fn replace(mut line: &[u8], from: &[u8], to: &[u8], out: &mut Vec<u8>) {
249    while let Some(at) = line.windows(from.len()).position(|w| w == from) {
250        out.extend_from_slice(&line[..at]);
251        out.extend_from_slice(to);
252        line = &line[at + from.len()..];
253    }
254    out.extend_from_slice(line);
255}
256
257/// The name of a `Name: value` header line.
258fn header_name(line: &[u8]) -> Option<&[u8]> {
259    line.iter().position(|b| *b == b':').map(|at| &line[..at])
260}
261
262fn contains(haystack: &[u8], needle: &[u8]) -> bool {
263    !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
264}
265
266#[cfg(test)]
267mod tests;