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. This module is the schema
5//! and its validation only: reading the source is the CLI's job, using the
6//! value is the proxy's.
7
8use serde::{Deserialize, Serialize};
9
10use crate::host::{MAX_DNS_LABEL_LEN, MAX_DNS_NAME_LEN};
11use crate::policy::PolicyError;
12
13/// What a brokered secret's id is prefixed with where it stands for a rule id
14/// (`secrets.<id>`): lint warnings, policy errors and sandbox losses.
15pub const SECRET_RULE_PREFIX: &str = "secrets.";
16
17/// Headers a secret may not be put in: they frame the request or the
18/// connection, so a secret there would change how the request is read.
19const RESERVED_HEADERS: &[&str] = &[
20    "host",
21    "content-length",
22    "transfer-encoding",
23    "connection",
24    "keep-alive",
25    "proxy-connection",
26    "proxy-authenticate",
27    "proxy-authorization",
28    "te",
29    "trailer",
30    "upgrade",
31];
32
33/// One brokered secret. Its `Debug` output names only the id: where the value
34/// lives and where it goes say which secrets exist.
35#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct Secret {
38    /// Stable identifier: lowercase letters, digits and `-`. It names the
39    /// placeholder and appears in audit rows; the value never does.
40    pub id: String,
41    /// The one host (a lowercase DNS name or IPv4 literal, no pattern) whose
42    /// requests receive the secret.
43    pub host: String,
44    /// The request header the secret goes in (`Authorization`, `X-Api-Key`).
45    pub header: String,
46    /// Where OpenMoat reads the value, written `{ env: NAME }` and so on.
47    pub source: Source,
48    /// Also inject into plain-HTTP requests, which carry the value in clear
49    /// text. Off unless set: without it a plain-HTTP request keeps the
50    /// placeholder.
51    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
52    pub plain_http: bool,
53    /// The port of the plain-HTTP requests that get the value, 1 to 65535;
54    /// 80 when unset. A request to another port of the host keeps the
55    /// placeholder. Only with `plain_http`.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub port: Option<u16>,
58}
59
60/// Where OpenMoat reads a secret's value. The agent should not be able to read it
61/// there; `moat policy lint` warns when no deny rule covers the source.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase", deny_unknown_fields)]
64pub enum Source {
65    /// A file, absolute or under `~/`; its contents without the trailing newline.
66    File(String),
67    /// An environment variable of the `moat proxy` process.
68    Env(String),
69    /// An item in the operating system's keychain.
70    Keychain {
71        /// Service name of the item.
72        service: String,
73        /// Account name of the item.
74        account: String,
75    },
76}
77
78impl std::fmt::Debug for Secret {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("Secret")
81            .field("id", &self.id)
82            .finish_non_exhaustive()
83    }
84}
85
86impl Secret {
87    /// What the agent holds instead of the value: `moat-secret:<id>:placeholder`.
88    /// Ids cannot contain `:`, so no placeholder occurs inside another.
89    #[must_use]
90    pub fn placeholder(&self) -> String {
91        format!("moat-secret:{}:placeholder", self.id)
92    }
93
94    /// The port of the plain-HTTP requests the value goes into: `port`, or 80.
95    #[must_use]
96    pub fn plain_http_port(&self) -> u16 {
97        self.port.unwrap_or(80)
98    }
99
100    /// True when the host is this machine: `localhost`, a name under it, or
101    /// `127.0.0.0/8`. Hosts are validated lowercase DNS names or IPv4. The
102    /// address type comes from `core`, which parses and does no I/O.
103    #[must_use]
104    pub fn is_loopback(&self) -> bool {
105        let host = self.host.as_str();
106        host == "localhost"
107            || host.ends_with(".localhost")
108            || host
109                .parse::<core::net::Ipv4Addr>()
110                .is_ok_and(|ip| ip.is_loopback())
111    }
112}
113
114/// Validate the `secrets:` list. Errors name the secret as `secrets.<id>`.
115pub(crate) fn check(secrets: &[Secret]) -> Result<(), PolicyError> {
116    for (i, secret) in secrets.iter().enumerate() {
117        let fail = |problem: String| PolicyError::Rule {
118            rule: format!("{SECRET_RULE_PREFIX}{}", secret.id),
119            problem,
120        };
121        check_one(secret).map_err(fail)?;
122        if let Some(earlier) = secrets[..i].iter().find(|s| s.id == secret.id) {
123            return Err(PolicyError::DuplicateId(format!(
124                "{SECRET_RULE_PREFIX}{}",
125                earlier.id
126            )));
127        }
128        if let Some(earlier) = secrets[..i]
129            .iter()
130            .find(|s| s.host == secret.host && s.header.eq_ignore_ascii_case(&secret.header))
131        {
132            return Err(fail(format!(
133                "secret `{}` already sets {} for {}",
134                earlier.id, secret.header, secret.host
135            )));
136        }
137    }
138    Ok(())
139}
140
141fn check_one(secret: &Secret) -> Result<(), String> {
142    let id = &secret.id;
143    if id.is_empty()
144        || id.len() > 64
145        || !id
146            .bytes()
147            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
148    {
149        return Err("id must be 1 to 64 lowercase letters, digits or `-`".into());
150    }
151    if !is_host(&secret.host) {
152        return Err(format!(
153            "host `{}` must be one lowercase DNS name or IPv4 address, without port or pattern",
154            secret.host
155        ));
156    }
157    let header = &secret.header;
158    if header.is_empty() || !header.bytes().all(is_token_byte) {
159        return Err(format!("header `{header}` is not an HTTP header name"));
160    }
161    if RESERVED_HEADERS.contains(&header.to_ascii_lowercase().as_str()) {
162        return Err(format!(
163            "header `{header}` frames the request or connection; it cannot carry a secret"
164        ));
165    }
166    match secret.port {
167        Some(0) => return Err("port must be 1 to 65535".into()),
168        Some(port) if !secret.plain_http => {
169            return Err(format!(
170                "port {port} names where plain-HTTP requests get the value; it needs \
171                 `plain_http: true`"
172            ));
173        }
174        _ => {}
175    }
176    match &secret.source {
177        Source::File(path) if !(crate::paths::is_absolute(path) || path.starts_with("~/")) => Err(
178            format!("source file `{path}` must be absolute or start with `~/`"),
179        ),
180        Source::Env(name) if !is_env_name(name) => {
181            Err(format!("source env `{name}` is not a variable name"))
182        }
183        Source::Keychain { service, account }
184            if [service, account]
185                .iter()
186                .any(|s| s.is_empty() || s.chars().any(char::is_control)) =>
187        {
188            Err("source keychain needs a service and an account without control characters".into())
189        }
190        _ => Ok(()),
191    }
192}
193
194/// The form `moat proxy` reports hosts in, so the comparison is exact.
195fn is_host(host: &str) -> bool {
196    host.len() <= MAX_DNS_NAME_LEN
197        && host.split('.').all(|label| {
198            !label.is_empty()
199                && label.len() <= MAX_DNS_LABEL_LEN
200                && label
201                    .bytes()
202                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
203        })
204}
205
206/// RFC 9110 `tchar`.
207fn is_token_byte(b: u8) -> bool {
208    b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
209}
210
211fn is_env_name(name: &str) -> bool {
212    let mut bytes = name.bytes();
213    bytes
214        .next()
215        .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
216        && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
217}
218
219#[cfg(test)]
220mod tests {
221    use crate::policy::{Policy, PolicyError};
222
223    use super::Source;
224
225    fn parse(secrets: &str) -> Result<Policy, PolicyError> {
226        Policy::parse(&format!("version: 1\nsecrets:\n{secrets}"))
227    }
228
229    fn problem(secrets: &str) -> String {
230        parse(secrets).unwrap_err().to_string()
231    }
232
233    const GH: &str = "  - id: gh\n    host: api.github.com\n    header: Authorization\n";
234
235    #[test]
236    fn parses_every_source() {
237        let p = parse(&format!(
238            "{GH}    source: {{ env: GITHUB_TOKEN }}\n  \
239             - id: npm\n    host: registry.npmjs.org\n    header: Authorization\n    \
240             source: {{ file: ~/.config/moat/npm }}\n  \
241             - id: k\n    host: example.com\n    header: X-Api-Key\n    \
242             source: {{ keychain: {{ service: moat, account: k }} }}\n"
243        ))
244        .unwrap();
245        assert_eq!(p.secrets.len(), 3);
246        assert_eq!(p.secrets[0].source, Source::Env("GITHUB_TOKEN".into()));
247        assert_eq!(
248            p.secrets[1].source,
249            Source::File("~/.config/moat/npm".into())
250        );
251        assert!(
252            matches!(&p.secrets[2].source, Source::Keychain { service, .. } if service == "moat")
253        );
254        assert_eq!(p.secrets[0].placeholder(), "moat-secret:gh:placeholder");
255        assert!(Policy::parse("version: 1\n").unwrap().secrets.is_empty());
256    }
257
258    #[test]
259    fn debug_output_names_only_the_id() {
260        let policy = parse(&format!("{GH}    source: {{ file: ~/.config/moat/gh }}\n")).unwrap();
261        let secret = &policy.secrets[0];
262        let brokered = crate::ir::BrokeredSecret {
263            id: secret.id.clone(),
264            host: secret.host.clone(),
265            header: secret.header.clone(),
266            source: secret.source.clone(),
267            plain_http: false,
268            port: None,
269        };
270        for shown in [format!("{policy:?}"), format!("{brokered:?}")] {
271            assert!(shown.contains("\"gh\""), "{shown}");
272            for hidden in ["~/.config/moat/gh", "api.github.com", "Authorization"] {
273                assert!(!shown.contains(hidden), "{hidden} in {shown}");
274            }
275        }
276    }
277
278    #[test]
279    fn plain_http_is_off_unless_set() {
280        let off = parse(&format!("{GH}    source: {{ env: T }}\n")).unwrap();
281        assert!(!off.secrets[0].plain_http);
282        let on = parse(&format!(
283            "{GH}    source: {{ env: T }}\n    plain_http: true\n"
284        ))
285        .unwrap();
286        assert!(on.secrets[0].plain_http);
287        assert!(!on.secrets[0].is_loopback());
288        for (host, loopback) in [
289            ("localhost", true),
290            ("api.localhost", true),
291            ("127.0.0.2", true),
292            ("10.0.0.1", false),
293            ("localhost.example", false),
294        ] {
295            let mut s = on.secrets[0].clone();
296            s.host = host.into();
297            assert_eq!(s.is_loopback(), loopback, "{host}");
298        }
299    }
300
301    #[test]
302    fn a_plain_http_secret_goes_to_port_80_unless_it_names_one() {
303        let plain = format!("{GH}    source: {{ env: T }}\n    plain_http: true\n");
304        assert_eq!(parse(&plain).unwrap().secrets[0].plain_http_port(), 80);
305        let on = parse(&format!("{plain}    port: 8080\n")).unwrap();
306        assert_eq!(on.secrets[0].port, Some(8080));
307        assert_eq!(on.secrets[0].plain_http_port(), 8080);
308        assert!(problem(&format!("{plain}    port: 0\n")).contains("1 to 65535"));
309        for bad in ["65536", "-1", "80.5", "http", "[80, 81]"] {
310            assert!(
311                parse(&format!("{plain}    port: {bad}\n")).is_err(),
312                "{bad}"
313            );
314        }
315        let without = format!("{GH}    source: {{ env: T }}\n    port: 8080\n");
316        assert!(problem(&without).contains("needs `plain_http: true`"));
317    }
318
319    #[test]
320    fn rejects_bad_fields() {
321        let src = "    source: { env: T }\n";
322        for (yaml, want) in [
323            (format!("  - id: Gh\n    host: a.com\n    header: A\n{src}"), "id must be"),
324            (format!("  - id: g:h\n    host: a.com\n    header: A\n{src}"), "id must be"),
325            (format!("  - id: g\n    host: '*.a.com'\n    header: A\n{src}"), "host `*.a.com`"),
326            (format!("  - id: g\n    host: A.com\n    header: A\n{src}"), "host `A.com`"),
327            (format!("  - id: g\n    host: a.com:443\n    header: A\n{src}"), "host `a.com:443`"),
328            (format!("  - id: g\n    host: a.com\n    header: 'A B'\n{src}"), "not an HTTP header"),
329            (format!("  - id: g\n    host: a.com\n    header: Host\n{src}"), "frames the request"),
330            (format!("  - id: g\n    host: a.com\n    header: content-length\n{src}"), "frames"),
331            ("  - id: g\n    host: a.com\n    header: A\n    source: { file: rel/t }\n".into(), "absolute"),
332            ("  - id: g\n    host: a.com\n    header: A\n    source: { env: 'A-B' }\n".into(), "variable name"),
333            (
334                "  - id: g\n    host: a.com\n    header: A\n    source: { keychain: { service: '', account: a } }\n".into(),
335                "keychain",
336            ),
337        ] {
338            let got = problem(&yaml);
339            assert!(got.contains(want), "{yaml}: {got}");
340        }
341        assert!(parse(&format!("{GH}    source: {{ vault: x }}\n")).is_err());
342        assert!(parse(&format!("{GH}    source: {{ env: T }}\n    extra: 1\n")).is_err());
343    }
344
345    #[test]
346    fn rejects_duplicates() {
347        let two = format!("{GH}    source: {{ env: A }}\n{GH}    source: {{ env: B }}\n");
348        assert!(matches!(parse(&two), Err(PolicyError::DuplicateId(id)) if id == "secrets.gh"));
349        let same_header = format!(
350            "{GH}    source: {{ env: A }}\n  - id: gh2\n    host: api.github.com\n    header: authorization\n    source: {{ env: B }}\n"
351        );
352        assert!(problem(&same_header).contains("already sets"));
353    }
354}