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