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