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