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