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