Skip to main content

pam_ssh_agent/
filter.rs

1use crate::cmd;
2use crate::environment::get_uid;
3use anyhow::Result;
4use anyhow::anyhow;
5use log::{debug, info};
6use ssh_agent_client_rs::Identity;
7use ssh_agent_client_rs::Identity::{Certificate, PublicKey};
8use ssh_key::AuthorizedKeys;
9use ssh_key::public::KeyData;
10use std::collections::HashSet;
11use std::fs;
12use std::path::Path;
13use std::time::Duration;
14use uzers::uid_t;
15
16/// An IdentityFilter can determine if an Identity provided by the ssh-agent is trusted or not
17/// by this plugin. It is constructed from files or commands providing regular ssh keys or
18/// cert-authority keys.
19pub struct IdentityFilter {
20    keys: HashSet<KeyData>,
21    ca_keys: HashSet<KeyData>,
22}
23
24impl IdentityFilter {
25    /// Construct a new Identity filter with the provided authorized_keys file and optionally
26    /// also ca_keys_file, authorized_keys_command and authorized_keys_command_user.
27    /// The authorized_keys_command will be invoked when specified, and its output will be treated
28    /// as additional lines in the authorized_keys file.
29    /// If authorized_keys_command_user is not specified, the identity of the calling user will
30    /// be used when executing he command.
31    pub fn new(
32        authorized_keys_file: &Path,
33        ca_keys_file: Option<&Path>,
34        authorized_keys_command: Option<&str>,
35        authorized_keys_command_user: Option<&str>,
36        calling_user: &str,
37    ) -> Result<Self> {
38        let mut identities = Vec::new();
39        if authorized_keys_file.exists() {
40            identities.extend(from_file(authorized_keys_file, false)?);
41        } else if ca_keys_file.is_none() && authorized_keys_command.is_none() {
42            info!("No valid keys for authentication, {authorized_keys_file:?} does not exist");
43        }
44
45        if let Some(ca_keys_file) = ca_keys_file {
46            identities.extend(from_file(ca_keys_file, true)?);
47        }
48
49        if let Some(cmd) = authorized_keys_command {
50            let user = authorized_keys_command_user.unwrap_or(calling_user);
51            identities.extend(from_command(cmd, get_uid(user)?, calling_user)?);
52        }
53        Self::from(identities)
54    }
55
56    pub fn from_authorized_file(authorized_keys_file: &Path) -> Result<Self> {
57        Self::new(authorized_keys_file, None, None, None, "")
58    }
59
60    fn from(authorized: Vec<Authorized>) -> Result<Self> {
61        let mut keys: HashSet<KeyData> = HashSet::new();
62        let mut ca_keys: HashSet<KeyData> = HashSet::new();
63
64        for item in authorized {
65            match item {
66                Authorized::Key(key) => keys.insert(key),
67                Authorized::CAKey(ca_key) => ca_keys.insert(ca_key),
68            };
69        }
70
71        Ok(Self { keys, ca_keys })
72    }
73
74    /// Returns true if the provided Identity is a PublicKey and this filter is configured
75    /// with the same public key, or if the Identity is a Certificate and this filter is
76    /// configured with a matching cert authority key. Please note that for certificates
77    /// this is not enough, see auth::validate_cert for more information.
78    pub fn filter(&self, identity: &Identity) -> bool {
79        match identity {
80            PublicKey(key) => {
81                if self.keys.contains(key.key_data()) {
82                    debug!(
83                        "found a matching key: {}",
84                        key.fingerprint(Default::default())
85                    );
86                    return true;
87                }
88            }
89            Certificate(cert) => {
90                let ca_key = cert.signature_key();
91                if self.ca_keys.contains(ca_key) {
92                    debug!(
93                        "found a matching cert-authority key: {}",
94                        ca_key.fingerprint(Default::default())
95                    );
96                    return true;
97                }
98            }
99        }
100        false
101    }
102}
103
104enum Authorized {
105    Key(KeyData),
106    CAKey(KeyData),
107}
108const MAX_DURATION: Duration = Duration::from_secs(10);
109
110fn from_command(command: &str, uid: uid_t, calling_user: &str) -> Result<Vec<Authorized>> {
111    debug!(
112        "Invoking command '{command} {calling_user}' to obtain public keys for user {calling_user}"
113    );
114    let buf = cmd::run(&[command, calling_user], MAX_DURATION, uid, None)?;
115    from_str(&buf, &format!("{command}:(output):"), false)
116}
117
118fn from_file(filename: &Path, ca_keys: bool) -> Result<Vec<Authorized>> {
119    let contents = fs::read_to_string(filename)?;
120    from_str(
121        &contents,
122        filename.to_str().ok_or(anyhow!("invalid filename"))?,
123        ca_keys,
124    )
125}
126
127fn from_str(buf: &str, what: &str, ca_keys: bool) -> Result<Vec<Authorized>> {
128    let keys: AuthorizedKeys = AuthorizedKeys::new(buf);
129    let iter = keys.enumerate().filter_map(move |(i, ak)| match ak {
130        Ok(entry) => {
131            let key_data = entry.public_key().key_data().to_owned();
132            if !ca_keys && !entry.config_opts().iter().any(|o| o == "cert-authority") {
133                return Some(Authorized::Key(key_data));
134            }
135            Some(Authorized::CAKey(key_data))
136        }
137        Err(e) => {
138            info!("Failed to parse line {what}:{i}': {e}");
139            None
140        }
141    });
142    Ok(iter.collect())
143}
144
145#[cfg(test)]
146mod tests {
147    use crate::filter::IdentityFilter;
148    use crate::test::{CERT_STR, data};
149    use ssh_agent_client_rs::Identity;
150    use ssh_key::{Certificate, PublicKey};
151    use std::env;
152    use std::path::Path;
153
154    #[test]
155    fn test_read_public_keys() -> anyhow::Result<()> {
156        let path = Path::new(data!("authorized_keys"));
157
158        let filter = IdentityFilter::from_authorized_file(path)?;
159
160        // authorized_keys contains the certificate authority key for the CERT_STR cert
161        let cert = Certificate::from_openssh(CERT_STR)?;
162        let identity: Identity = cert.into();
163        assert!(filter.filter(&identity));
164
165        // verify that when using the ca_keys_file parameter, we can use the raw key and don't need
166        // the 'cert-authority ' prefix.
167        let filter = IdentityFilter::new(
168            // an empty file works for our purposes
169            Path::new("/dev/null"),
170            Some(Path::new(data!("ca_key.pub"))),
171            None,
172            None,
173            "",
174        )?;
175        assert!(filter.filter(&identity));
176
177        // check that we the fact that the authorized_keys file does not exist if ca_keys_file does
178        let filter = IdentityFilter::new(
179            // an empty file works for our purposes
180            Path::new("/does/not/exist"),
181            Some(Path::new(data!("ca_key.pub"))),
182            None,
183            None,
184            "",
185        )?;
186        assert!(filter.filter(&identity));
187
188        Ok(())
189    }
190
191    // this test needs to be run as root, as otherwise it would not be possible to
192    // drop privileges
193    #[test]
194    #[ignore]
195    fn test_invoke_command_for_public_keys() -> anyhow::Result<()> {
196        let filter = IdentityFilter::new(
197            Path::new("/dev/null"),
198            None,
199            Some(data!("test.sh")),
200            None,
201            &env::var("USER")?,
202        )?;
203        let identity: Identity =
204            PublicKey::from_openssh(include_str!(data!("id_ed25519.pub")))?.into();
205        assert!(filter.filter(&identity));
206        Ok(())
207    }
208}