Skip to main content

openmoat_core/secret/
command.rs

1//! A brokered secret read from a program the person chose (ADR-087): the
2//! schema of `source: { command: … }` and its validation. Running the program
3//! is the CLI's job (`secrets/command.rs`), and only `openmoat proxy` does it.
4
5use serde::{Deserialize, Serialize};
6
7/// The shortest `lifetime` a command source may have.
8pub const MIN_LIFETIME_SECS: u64 = 60;
9/// The longest `lifetime` a command source may have: a value older than this
10/// is not short-lived.
11pub const MAX_LIFETIME_SECS: u64 = 12 * 60 * 60;
12/// The shortest `refresh_before`: the program gets at least this long to
13/// answer before the value it replaces expires.
14pub const MIN_REFRESH_BEFORE_SECS: u64 = 10;
15/// The most arguments a command source may name, the program included.
16pub const MAX_ARGS: usize = 64;
17
18/// Folders every user can write: a program there could be replaced by
19/// anyone, the agent included. Compared on the path as written; the CLI checks
20/// the resolved file as well. (macOS's per-user `/var/folders` is the user's
21/// own, like the home: a deny rule must cover a program there.)
22const SHARED_TEMP: &[&str] = &[
23    "/tmp/",
24    "/private/tmp/",
25    "/var/tmp/",
26    "/private/var/tmp/",
27    "/dev/shm/",
28];
29
30/// `source: { command: { run: [...], lifetime: 15m, refresh_before: 2m } }`.
31/// `Debug` names the program only: arguments can name accounts and projects.
32#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct CommandSource {
35    /// The program, absolute or under `~/`, then its arguments. Run without a
36    /// shell: no word is expanded.
37    pub run: Vec<String>,
38    /// The longest a value is used, `90s`, `15m` or `1h`, from 1 minute to 12
39    /// hours. A shorter expiry the program prints wins.
40    pub lifetime: String,
41    /// How long before the value expires the proxy asks for a new one; a fifth
42    /// of `lifetime` when unset.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub refresh_before: Option<String>,
45    /// The `HOME` the program gets: an empty folder of its own unless set to
46    /// `user`.
47    #[serde(default, skip_serializing_if = "CommandHome::is_private")]
48    pub home: CommandHome,
49}
50
51/// `home:` of a command source: what the program finds in `HOME`.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum CommandHome {
55    /// An empty folder of its own under OpenMoat's state directory: the
56    /// program reads no dotfile of the person's, nor one the agent planted.
57    #[default]
58    Private,
59    /// The person's home: for a tool that keeps its login there (`~/.vault-token`).
60    /// The program then reads files there the agent may be able to change.
61    User,
62}
63
64impl CommandHome {
65    /// Whether this is the default, [`CommandHome::Private`].
66    #[must_use]
67    pub fn is_private(&self) -> bool {
68        *self == Self::Private
69    }
70}
71
72impl std::fmt::Debug for CommandSource {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("CommandSource")
75            .field("program", &self.program())
76            .finish_non_exhaustive()
77    }
78}
79
80impl CommandSource {
81    /// The program as written: the first word of `run`.
82    #[must_use]
83    pub fn program(&self) -> &str {
84        self.run.first().map_or("", String::as_str)
85    }
86
87    /// `lifetime` in seconds. Validated when the policy loaded.
88    #[must_use]
89    pub fn lifetime_secs(&self) -> u64 {
90        duration_secs(&self.lifetime).unwrap_or(MIN_LIFETIME_SECS)
91    }
92
93    /// The folder every user can write that `path` (slash-separated) lies in,
94    /// if any: no program there is the person's alone.
95    #[must_use]
96    pub fn shared_temp_dir(path: &str) -> Option<&'static str> {
97        SHARED_TEMP
98            .iter()
99            .copied()
100            .find(|dir| path.starts_with(dir))
101    }
102
103    /// `refresh_before` in seconds, or a fifth of the lifetime, never less
104    /// than 10 seconds.
105    #[must_use]
106    pub fn refresh_before_secs(&self) -> u64 {
107        self.refresh_before
108            .as_deref()
109            .and_then(duration_secs)
110            .unwrap_or(self.lifetime_secs() / 5)
111            .max(MIN_REFRESH_BEFORE_SECS)
112    }
113}
114
115/// Why `source` cannot be used, if it cannot.
116pub(super) fn check(source: &CommandSource) -> Result<(), String> {
117    let program = source.program();
118    if source.run.is_empty() || source.run.len() > MAX_ARGS {
119        return Err(format!(
120            "source command `run` needs a program and at most {} arguments",
121            MAX_ARGS - 1
122        ));
123    }
124    if source
125        .run
126        .iter()
127        .any(|word| word.chars().any(char::is_control))
128    {
129        return Err("source command `run` holds a control character".into());
130    }
131    if !(crate::paths::is_absolute(program) || program.starts_with("~/")) {
132        return Err(format!(
133            "source command `{program}` must be an absolute path or start with `~/`: a bare \
134             name is looked up in a search path the agent may change"
135        ));
136    }
137    let slashed = program.replace('\\', "/");
138    if slashed.split('/').any(|part| part == ".." || part == ".") {
139        return Err(format!(
140            "source command `{program}` must not name `.` or `..`"
141        ));
142    }
143    if let Some(dir) = CommandSource::shared_temp_dir(&slashed) {
144        return Err(format!(
145            "source command `{program}` is under {dir}, where any user, the agent too, can put \
146             a file"
147        ));
148    }
149    let lifetime = duration_secs(&source.lifetime).ok_or_else(|| {
150        format!(
151            "source command lifetime `{}` is not a duration such as `90s`, `15m` or `1h`",
152            source.lifetime
153        )
154    })?;
155    if !(MIN_LIFETIME_SECS..=MAX_LIFETIME_SECS).contains(&lifetime) {
156        return Err(format!(
157            "source command lifetime `{}` must be from 1m to 12h",
158            source.lifetime
159        ));
160    }
161    if let Some(before) = &source.refresh_before {
162        let secs = duration_secs(before).ok_or_else(|| {
163            format!("source command refresh_before `{before}` is not a duration such as `2m`")
164        })?;
165        if secs < MIN_REFRESH_BEFORE_SECS || secs >= lifetime {
166            return Err(format!(
167                "source command refresh_before `{before}` must be at least 10s and shorter than \
168                 the lifetime"
169            ));
170        }
171    }
172    Ok(())
173}
174
175/// Seconds in `text`: digits then one unit, `s`, `m` or `h`.
176#[must_use]
177pub fn duration_secs(text: &str) -> Option<u64> {
178    let unit = match text.as_bytes().last()? {
179        b's' => 1,
180        b'm' => 60,
181        b'h' => 60 * 60,
182        _ => return None,
183    };
184    let digits = &text[..text.len() - 1];
185    if digits.is_empty() || digits.len() > 6 || !digits.bytes().all(|b| b.is_ascii_digit()) {
186        return None;
187    }
188    digits.parse::<u64>().ok().map(|n| n * unit)
189}