Skip to main content

openmoat_core/
secret.rs

1//! Brokered secrets: the policy's `secrets:` list (ADR-020, docs/POLICY.md ยง2.1).
2//!
3//! OpenMoat keeps the value; the agent holds a placeholder; `moat proxy` puts the
4//! value into one header of requests to one host, and with `allow` rules only
5//! of the requests they name (`route.rs`, ADR-043). This module is the schema,
6//! its validation and the rules' matching: reading the source is the CLI's
7//! job, using the value is the proxy's.
8
9mod route;
10
11use serde::{Deserialize, Serialize};
12
13pub use self::route::{MAX_ROUTES, METHOD_OVERRIDE_HEADERS, PathRefusal, RequestPath, Route};
14use crate::host::{MAX_DNS_LABEL_LEN, MAX_DNS_NAME_LEN};
15use crate::policy::PolicyError;
16
17/// What a brokered secret's id is prefixed with where it stands for a rule id
18/// (`secrets.<id>`): lint warnings, policy errors and sandbox losses.
19pub const SECRET_RULE_PREFIX: &str = "secrets.";
20
21/// Headers a secret may not be put in: they frame the request or the
22/// connection, so a secret there would change how the request is read.
23const RESERVED_HEADERS: &[&str] = &[
24    "host",
25    "content-length",
26    "transfer-encoding",
27    "connection",
28    "keep-alive",
29    "proxy-connection",
30    "proxy-authenticate",
31    "proxy-authorization",
32    "te",
33    "trailer",
34    "upgrade",
35];
36
37/// One brokered secret. Its `Debug` output names only the id: where the value
38/// lives and where it goes say which secrets exist.
39#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct Secret {
42    /// Stable identifier: lowercase letters, digits and `-`. It names the
43    /// placeholder and appears in audit rows; the value never does.
44    pub id: String,
45    /// The one host (a lowercase DNS name or IPv4 literal, no pattern) whose
46    /// requests receive the secret.
47    pub host: String,
48    /// The request header the secret goes in (`Authorization`, `X-Api-Key`).
49    pub header: String,
50    /// Where OpenMoat reads the value, written `{ env: NAME }` and so on.
51    pub source: Source,
52    /// Also inject into plain-HTTP requests, which carry the value in clear
53    /// text. Off unless set: without it a plain-HTTP request keeps the
54    /// placeholder.
55    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
56    pub plain_http: bool,
57    /// The port of the plain-HTTP requests that get the value, 1 to 65535;
58    /// 80 when unset. A request to another port of the host keeps the
59    /// placeholder. Only with `plain_http`.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub port: Option<u16>,
62    /// `terminate`: `openmoat run`'s proxy decrypts HTTPS to `host` with its own
63    /// certificate authority and puts the value in there (ADR-043). Off unless
64    /// set: without it an HTTPS request keeps the placeholder.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub tls: Option<Tls>,
67    /// The only requests that may receive the value, `METHOD /path` each
68    /// ([`Route`]). Empty: every request to `host` does. With rules, any other
69    /// request to `host` that would get the value is refused before it is
70    /// added. Needs `tls: terminate` or `plain_http`, where requests can be read.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub allow: Vec<String>,
73}
74
75/// How a brokered secret reaches HTTPS requests.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum Tls {
79    /// The proxy ends the agent's TLS with a certificate it makes for the host,
80    /// reads each request, and opens its own TLS to the host.
81    Terminate,
82}
83
84/// What a secret's `allow` rules say about one request.
85#[derive(Debug, Clone, PartialEq, Eq)]
86#[non_exhaustive]
87pub enum Permit {
88    /// The secret has no rules: every request to its host gets it.
89    Unrestricted,
90    /// The request matches this rule.
91    Rule(String),
92    /// The request matches no rule, or cannot be matched; why, without the path.
93    Refused(String),
94}
95
96/// Where OpenMoat reads a secret's value. The agent should not be able to read it
97/// there; `moat policy lint` warns when no deny rule covers the source.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "lowercase", deny_unknown_fields)]
100pub enum Source {
101    /// A file, absolute or under `~/`; its contents without the trailing newline.
102    File(String),
103    /// An environment variable of the `moat proxy` process.
104    Env(String),
105    /// An item in the operating system's keychain.
106    Keychain {
107        /// Service name of the item.
108        service: String,
109        /// Account name of the item.
110        account: String,
111    },
112}
113
114impl std::fmt::Debug for Secret {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("Secret")
117            .field("id", &self.id)
118            .finish_non_exhaustive()
119    }
120}
121
122impl Secret {
123    /// What the agent holds instead of the value: `moat-secret:<id>:placeholder`.
124    /// Ids cannot contain `:`, so no placeholder occurs inside another.
125    #[must_use]
126    pub fn placeholder(&self) -> String {
127        format!("moat-secret:{}:placeholder", self.id)
128    }
129
130    /// The port of the plain-HTTP requests the value goes into: `port`, or 80.
131    #[must_use]
132    pub fn plain_http_port(&self) -> u16 {
133        self.port.unwrap_or(80)
134    }
135
136    /// True when the host is this machine: `localhost`, a name under it, or
137    /// `127.0.0.0/8`. Hosts are validated lowercase DNS names or IPv4. The
138    /// address type comes from `core`, which parses and does no I/O.
139    #[must_use]
140    pub fn is_loopback(&self) -> bool {
141        let host = self.host.as_str();
142        host == "localhost"
143            || host.ends_with(".localhost")
144            || host
145                .parse::<core::net::Ipv4Addr>()
146                .is_ok_and(|ip| ip.is_loopback())
147    }
148
149    /// Whether `openmoat run`'s proxy decrypts HTTPS to the host (`tls: terminate`).
150    #[must_use]
151    pub fn terminates(&self) -> bool {
152        self.tls == Some(Tls::Terminate)
153    }
154
155    /// What the `allow` rules say about a `method` request for `target` (the
156    /// request line's target) whose headers are named `header_names`. With
157    /// rules, a request is refused when its path could be read as another
158    /// ([`PathRefusal`]) or it asks for another method ([`METHOD_OVERRIDE_HEADERS`],
159    /// a `_method` query parameter). Reasons never quote the path: it may hold
160    /// what the request is about.
161    #[must_use]
162    pub fn permit(&self, method: &str, target: &str, header_names: &[&str]) -> Permit {
163        if self.allow.is_empty() {
164            return Permit::Unrestricted;
165        }
166        let refused = |why: String| {
167            Permit::Refused(format!(
168                "{method} request to {} is refused for secret `{}`: {why}",
169                self.host, self.id
170            ))
171        };
172        let path = match RequestPath::parse(target) {
173            Ok(path) => path,
174            Err(why) => return refused(why.to_string()),
175        };
176        if let Some(name) = header_names.iter().find(|name| {
177            METHOD_OVERRIDE_HEADERS
178                .iter()
179                .any(|h| name.eq_ignore_ascii_case(h))
180        }) {
181            return refused(format!("its `{name}` header asks for another method"));
182        }
183        if path.overrides_method() {
184            return refused("its query names a `_method` parameter".to_owned());
185        }
186        // Validated when the policy loaded, so every rule parses.
187        self.allow
188            .iter()
189            .find(|rule| Route::parse(rule).is_ok_and(|r| r.matches(method, &path)))
190            .map_or_else(
191                || refused("it matches none of the secret's `allow` rules".to_owned()),
192                |rule| Permit::Rule(rule.clone()),
193            )
194    }
195}
196
197/// Validate the `secrets:` list. Errors name the secret as `secrets.<id>`.
198pub(crate) fn check(secrets: &[Secret]) -> Result<(), PolicyError> {
199    for (i, secret) in secrets.iter().enumerate() {
200        let fail = |problem: String| PolicyError::Rule {
201            rule: format!("{SECRET_RULE_PREFIX}{}", secret.id),
202            problem,
203        };
204        check_one(secret).map_err(fail)?;
205        if let Some(earlier) = secrets[..i].iter().find(|s| s.id == secret.id) {
206            return Err(PolicyError::DuplicateId(format!(
207                "{SECRET_RULE_PREFIX}{}",
208                earlier.id
209            )));
210        }
211        if let Some(earlier) = secrets[..i]
212            .iter()
213            .find(|s| s.host == secret.host && s.header.eq_ignore_ascii_case(&secret.header))
214        {
215            return Err(fail(format!(
216                "secret `{}` already sets {} for {}",
217                earlier.id, secret.header, secret.host
218            )));
219        }
220    }
221    Ok(())
222}
223
224fn check_one(secret: &Secret) -> Result<(), String> {
225    let id = &secret.id;
226    if id.is_empty()
227        || id.len() > 64
228        || !id
229            .bytes()
230            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
231    {
232        return Err("id must be 1 to 64 lowercase letters, digits or `-`".into());
233    }
234    if !is_host(&secret.host) {
235        return Err(format!(
236            "host `{}` must be one lowercase DNS name or IPv4 address, without port or pattern",
237            secret.host
238        ));
239    }
240    let header = &secret.header;
241    if header.is_empty() || !header.bytes().all(is_token_byte) {
242        return Err(format!("header `{header}` is not an HTTP header name"));
243    }
244    if RESERVED_HEADERS.contains(&header.to_ascii_lowercase().as_str()) {
245        return Err(format!(
246            "header `{header}` frames the request or connection; it cannot carry a secret"
247        ));
248    }
249    match secret.port {
250        Some(0) => return Err("port must be 1 to 65535".into()),
251        Some(port) if !secret.plain_http => {
252            return Err(format!(
253                "port {port} names where plain-HTTP requests get the value; it needs \
254                 `plain_http: true`"
255            ));
256        }
257        _ => {}
258    }
259    check_transport(secret)?;
260    match &secret.source {
261        Source::File(path) if !(crate::paths::is_absolute(path) || path.starts_with("~/")) => Err(
262            format!("source file `{path}` must be absolute or start with `~/`"),
263        ),
264        Source::Env(name) if !is_env_name(name) => {
265            Err(format!("source env `{name}` is not a variable name"))
266        }
267        Source::Keychain { service, account }
268            if [service, account]
269                .iter()
270                .any(|s| s.is_empty() || s.chars().any(char::is_control)) =>
271        {
272            Err("source keychain needs a service and an account without control characters".into())
273        }
274        _ => Ok(()),
275    }
276}
277
278/// `tls` and `allow`: termination names a host a certificate can name, and
279/// rules need requests the proxy can read.
280fn check_transport(secret: &Secret) -> Result<(), String> {
281    if secret.terminates() && secret.host.parse::<core::net::Ipv4Addr>().is_ok() {
282        return Err(format!(
283            "`tls: terminate` needs a DNS name; {} is an address, which the proxy's \
284             certificate authority may not sign for",
285            secret.host
286        ));
287    }
288    if secret.allow.is_empty() {
289        return Ok(());
290    }
291    if !secret.terminates() && !secret.plain_http {
292        return Err(
293            "`allow` rules need `tls: terminate` or `plain_http: true`: elsewhere the proxy \
294             cannot read the request"
295                .into(),
296        );
297    }
298    if secret.allow.len() > MAX_ROUTES {
299        return Err(format!("`allow` lists more than {MAX_ROUTES} rules"));
300    }
301    secret
302        .allow
303        .iter()
304        .try_for_each(|rule| Route::parse(rule).map(drop))
305}
306
307/// The form `moat proxy` reports hosts in, so the comparison is exact.
308fn is_host(host: &str) -> bool {
309    host.len() <= MAX_DNS_NAME_LEN
310        && host.split('.').all(|label| {
311            !label.is_empty()
312                && label.len() <= MAX_DNS_LABEL_LEN
313                && label
314                    .bytes()
315                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
316        })
317}
318
319/// RFC 9110 `tchar`.
320fn is_token_byte(b: u8) -> bool {
321    b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
322}
323
324fn is_env_name(name: &str) -> bool {
325    let mut bytes = name.bytes();
326    bytes
327        .next()
328        .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
329        && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
330}
331
332#[cfg(test)]
333mod tests;