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