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 where path is the path to a file in authorized_keys
26    /// format, and the ca_keys_file is an optional path to a file containing cert-authority
27    /// keys. See README.md for the details on the expected file format.
28    pub fn new(
29        authorized_keys_file: &Path,
30        ca_keys_file: Option<&Path>,
31        authorized_keys_command: Option<&str>,
32        authorized_keys_command_user: Option<&str>,
33        calling_user: &str,
34    ) -> Result<Self> {
35        let mut identities = Vec::new();
36        if authorized_keys_file.exists() {
37            identities.extend(from_file(authorized_keys_file, false)?);
38        } else if ca_keys_file.is_none() && authorized_keys_command.is_none() {
39            info!("No valid keys for authentication, {authorized_keys_file:?} does not exist");
40        }
41
42        if let Some(ca_keys_file) = ca_keys_file {
43            identities.extend(from_file(ca_keys_file, true)?);
44        }
45
46        if let Some(cmd) = authorized_keys_command {
47            let user = authorized_keys_command_user.map(get_uid).transpose()?;
48            identities.extend(from_command(cmd, user, calling_user)?);
49        }
50        Self::from(identities)
51    }
52
53    pub fn from_authorized_file(authorized_keys_file: &Path) -> Result<Self> {
54        Self::new(authorized_keys_file, None, None, None, "")
55    }
56
57    fn from(authorized: Vec<Authorized>) -> Result<Self> {
58        let mut keys: HashSet<KeyData> = HashSet::new();
59        let mut ca_keys: HashSet<KeyData> = HashSet::new();
60
61        for item in authorized {
62            match item {
63                Authorized::Key(key) => keys.insert(key),
64                Authorized::CAKey(ca_key) => ca_keys.insert(ca_key),
65            };
66        }
67
68        Ok(Self { keys, ca_keys })
69    }
70
71    /// Returns true if the provided Identity is a PublicKey and this filter is configured
72    /// with the same public key, or if the Identity is a Certificate and this filter is
73    /// configured with a matching cert authority key. Please note that for certificates
74    /// this is not enough, see auth::validate_cert for more information.
75    pub fn filter(&self, identity: &Identity) -> bool {
76        match identity {
77            PublicKey(key) => {
78                if self.keys.contains(key.key_data()) {
79                    debug!(
80                        "found a matching key: {}",
81                        key.fingerprint(Default::default())
82                    );
83                    return true;
84                }
85            }
86            Certificate(cert) => {
87                let ca_key = cert.signature_key();
88                if self.ca_keys.contains(ca_key) {
89                    debug!(
90                        "found a matching cert-authority key: {}",
91                        ca_key.fingerprint(Default::default())
92                    );
93                    return true;
94                }
95            }
96        }
97        false
98    }
99}
100
101enum Authorized {
102    Key(KeyData),
103    CAKey(KeyData),
104}
105const MAX_DURATION: Duration = Duration::from_secs(10);
106
107fn from_command(command: &str, uid: Option<uid_t>, arg: &str) -> Result<Vec<Authorized>> {
108    debug!("Invoking command '{command} {arg}' to obtain public keys for user {arg}");
109    let buf = cmd::run(&[command, arg], MAX_DURATION, uid)?;
110    from_str(&buf, &format!("{command}:(output):"), false)
111}
112
113fn from_file(filename: &Path, ca_keys: bool) -> Result<Vec<Authorized>> {
114    let contents = fs::read_to_string(filename)?;
115    from_str(
116        &contents,
117        filename.to_str().ok_or(anyhow!("invalid filename"))?,
118        ca_keys,
119    )
120}
121
122fn from_str(buf: &str, what: &str, ca_keys: bool) -> Result<Vec<Authorized>> {
123    let keys: AuthorizedKeys = AuthorizedKeys::new(buf);
124    let iter = keys.enumerate().filter_map(move |(i, ak)| match ak {
125        Ok(entry) => {
126            let key_data = entry.public_key().key_data().to_owned();
127            if !ca_keys && !entry.config_opts().iter().any(|o| o == "cert-authority") {
128                return Some(Authorized::Key(key_data));
129            }
130            Some(Authorized::CAKey(key_data))
131        }
132        Err(e) => {
133            info!("Failed to parse line {what}:{i}': {e}");
134            None
135        }
136    });
137    Ok(iter.collect())
138}
139
140#[cfg(test)]
141mod tests {
142    use crate::filter::IdentityFilter;
143    use crate::test::{CERT_STR, data};
144    use ssh_agent_client_rs::Identity;
145    use ssh_key::{Certificate, PublicKey};
146    use std::path::Path;
147
148    #[test]
149    fn test_read_public_keys() -> anyhow::Result<()> {
150        let path = Path::new(data!("authorized_keys"));
151
152        let filter = IdentityFilter::from_authorized_file(path)?;
153
154        // authorized_keys contains the certificate authority key for the CERT_STR cert
155        let cert = Certificate::from_openssh(CERT_STR)?;
156        let identity: Identity = cert.into();
157        assert!(filter.filter(&identity));
158
159        // verify that when using the ca_keys_file parameter, we can use he raw key and don't need
160        // the 'cert-authority ' prefix.
161        let filter = IdentityFilter::new(
162            // an empty file works for our purposes
163            Path::new("/dev/null"),
164            Some(Path::new(data!("ca_key.pub"))),
165            None,
166            None,
167            "",
168        )?;
169        assert!(filter.filter(&identity));
170
171        // check that we the fact that the authorized_keys file does not exist if ca_keys_file does
172        let filter = IdentityFilter::new(
173            // an empty file works for our purposes
174            Path::new("/does/not/exist"),
175            Some(Path::new(data!("ca_key.pub"))),
176            None,
177            None,
178            "",
179        )?;
180        assert!(filter.filter(&identity));
181
182        let filter = IdentityFilter::new(
183            Path::new("/dev/null"),
184            None,
185            Some(data!("test.sh")),
186            None,
187            "user",
188        )?;
189        let identity: Identity =
190            PublicKey::from_openssh(include_str!(data!("id_ed25519.pub")))?.into();
191        assert!(filter.filter(&identity));
192
193        // test.sh returns 1 if first arg is not "user"
194        let Err(e) = IdentityFilter::new(
195            Path::new("/dev/null"),
196            None,
197            Some(data!("test.sh")),
198            None,
199            "not_user",
200        ) else {
201            panic!("test.sh should have failed");
202        };
203        assert!(format!("{:?}", e).contains("Non-zero exit status"));
204
205        Ok(())
206    }
207}