Skip to main content

biscuit_ssh_core/
request.rs

1//! Validated inputs of an access request.
2//!
3//! Server and account names end up in certificate principals (`server:user`)
4//! and in file paths on servers, so they are restricted to a conservative
5//! character set that keeps both unambiguous.
6
7use std::fmt;
8use std::net::IpAddr;
9use std::str::FromStr;
10use std::time::SystemTime;
11
12use crate::Error;
13
14/// Longest accepted server or account name.
15const MAX_NAME_LEN: usize = 64;
16
17/// Longest accepted forced command.
18const MAX_COMMAND_LEN: usize = 1024;
19
20/// A server or account name: 1 to 64 characters among `[A-Za-z0-9._-]`, not
21/// starting with `-` (so it can never be read as a command-line option).
22#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct Name(String);
24
25impl Name {
26    /// The name as validated.
27    pub fn as_str(&self) -> &str {
28        &self.0
29    }
30}
31
32impl FromStr for Name {
33    type Err = Error;
34
35    fn from_str(s: &str) -> Result<Self, Error> {
36        if s.is_empty() || s.len() > MAX_NAME_LEN {
37            return Err(Error::invalid("name", "must be 1 to 64 characters long"));
38        }
39        if s.starts_with('-') {
40            return Err(Error::invalid("name", "must not start with '-'"));
41        }
42        if !s
43            .bytes()
44            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
45        {
46            return Err(Error::invalid("name", "allowed characters are A-Z a-z 0-9 . _ -"));
47        }
48        Ok(Self(s.to_owned()))
49    }
50}
51
52impl fmt::Display for Name {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(&self.0)
55    }
56}
57
58/// A command to force on the SSH session: printable, single line, at most
59/// 1024 bytes.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Command(String);
62
63impl Command {
64    /// The command line as validated.
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68}
69
70impl FromStr for Command {
71    type Err = Error;
72
73    fn from_str(s: &str) -> Result<Self, Error> {
74        if s.trim().is_empty() || s.len() > MAX_COMMAND_LEN {
75            return Err(Error::invalid("command", "must be 1 to 1024 bytes long"));
76        }
77        if s.chars().any(char::is_control) {
78            return Err(Error::invalid("command", "must not contain control characters"));
79        }
80        Ok(Self(s.to_owned()))
81    }
82}
83
84/// How the access will be enforced, exposed to tokens as `mode(...)`.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Mode {
87    /// A short-lived OpenSSH certificate is issued to the client.
88    Certificate,
89    /// The client connects through the bastion.
90    Bastion,
91}
92
93impl Mode {
94    /// The value of the `mode` fact.
95    pub fn as_str(self) -> &'static str {
96        match self {
97            Self::Certificate => "certificate",
98            Self::Bastion => "bastion",
99        }
100    }
101}
102
103/// Everything the verifier knows about an access attempt.
104///
105/// Each field becomes a Datalog fact (see [`crate::vocabulary`]); the token
106/// and the verifier policies decide whether the attempt is allowed.
107#[derive(Debug, Clone)]
108pub struct AccessRequest {
109    /// When the request is evaluated.
110    pub time: SystemTime,
111    /// Client address as seen by the verifier (never taken from the client's own claims).
112    pub source_ip: IpAddr,
113    /// Server name, as in token rights (not a network address).
114    pub server: Name,
115    /// Account on that server.
116    pub user: Name,
117    /// Command the session will be restricted to, if any.
118    pub command: Option<Command>,
119    /// OpenSSH fingerprint (`SHA256:...`) of the holder's key: the one
120    /// certified, or the one that logged in to the bastion (not the
121    /// bastion's own key towards the server).
122    pub public_key: String,
123    /// How access will be enforced.
124    pub mode: Mode,
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn validates_names() {
133        for ok in ["web-1", "deploy", "db.eu_west.2", "a"] {
134            assert!(ok.parse::<Name>().is_ok(), "{ok}");
135        }
136        let long = "a".repeat(65);
137        for bad in ["", "-rf", "web:1", "a b", "é", "a,b", "a/b", long.as_str()] {
138            assert!(bad.parse::<Name>().is_err(), "{bad}");
139        }
140    }
141
142    #[test]
143    fn validates_commands() {
144        assert!("uptime".parse::<Command>().is_ok());
145        assert!("rsync --server -e.LsfxC . /srv".parse::<Command>().is_ok());
146        let long = "a".repeat(1025);
147        for bad in ["", "  ", "ls\nrm -rf /", "ls\0", long.as_str()] {
148            assert!(bad.parse::<Command>().is_err(), "{bad:?}");
149        }
150    }
151}