Skip to main content

biscuit_ssh_ca/
lib.rs

1//! OpenSSH user certificate authority of biscuit-ssh.
2//!
3//! Turns an authorized access request into a short-lived user certificate
4//! whose constraints are enforced by `sshd` itself:
5//!
6//! | Request | Certificate |
7//! |---|---|
8//! | `server`, `user` | single principal `server:user` (see [`principal`]) |
9//! | client address | critical option `source-address` = that address only |
10//! | command | critical option `force-command` |
11//! | token validity | `valid-before` = [`Authorization::valid_until`] |
12//!
13//! Through the bastion, [`CertificateAuthority::sign_relayed`] certifies the
14//! bastion's own key instead, with `source-address` pinned to the bastion.
15//!
16//! Only `permit-pty` is granted: no agent, port or X11 forwarding, and no
17//! `~/.ssh/rc`. Servers trust the CA with `TrustedUserCAKeys` and map
18//! accounts to principals with `AuthorizedPrincipalsFile`.
19//!
20//! `sshd` checks the certificate only when a session starts: an expiring
21//! certificate does not close sessions that are already open. Servers must
22//! keep their clock synchronized, since a lagging clock extends validity.
23
24#![warn(missing_docs)]
25
26use std::net::IpAddr;
27use std::path::Path;
28use std::time::Duration;
29
30use biscuit_ssh_core::{Authorization, Mode, Name, Network};
31use ssh_key::certificate::{Builder, CertType};
32use ssh_key::rand_core::{OsRng, RngCore};
33use ssh_key::{Algorithm, Certificate, HashAlg, PrivateKey, PublicKey};
34
35/// Why a certificate could not be issued.
36#[derive(Debug, thiserror::Error)]
37pub enum Error {
38    /// The CA private key cannot be used.
39    #[error("unusable CA key: {0}")]
40    Key(String),
41    /// The client public key is malformed or of a refused type.
42    #[error("unsupported public key: {0}")]
43    PublicKey(String),
44    /// The authorization does not cover this certificate.
45    #[error("refused: {0}")]
46    Mismatch(&'static str),
47    /// The certificate could not be built or signed.
48    #[error("cannot sign certificate: {0}")]
49    Signing(#[from] ssh_key::Error),
50}
51
52/// The principal a certificate carries for `user` on `server`.
53///
54/// On each server, `AuthorizedPrincipalsFile` lists for every account the
55/// principals allowed to log in as it, typically `<this server>:<account>`.
56/// Names cannot contain `:`, so the principal is unambiguous.
57pub fn principal(server: &Name, user: &Name) -> String {
58    format!("{server}:{user}")
59}
60
61/// Parses a client public key in OpenSSH format (`ssh-ed25519 AAAA...`).
62///
63/// Certificates are refused: a certificate must never be re-certified,
64/// and only plain keys can be signed.
65pub fn parse_public_key(openssh: &str) -> Result<PublicKey, Error> {
66    let key = PublicKey::from_openssh(openssh.trim()).map_err(|e| Error::PublicKey(e.to_string()))?;
67    if matches!(key.algorithm(), Algorithm::Dsa | Algorithm::Other(_)) {
68        return Err(Error::PublicKey(format!("{} keys are not accepted", key.algorithm())));
69    }
70    Ok(key)
71}
72
73/// The fingerprint tokens refer to in `public_key(...)` facts.
74pub fn fingerprint(key: &PublicKey) -> String {
75    key.fingerprint(HashAlg::Sha256).to_string()
76}
77
78/// Signs user certificates with the CA private key.
79pub struct CertificateAuthority {
80    key: PrivateKey,
81}
82
83impl CertificateAuthority {
84    /// Loads an unencrypted OpenSSH private key.
85    ///
86    /// On Unix, the file must not be readable by group or others, as with
87    /// `ssh` itself: a leaked CA key gives access to every server.
88    pub fn load(path: &Path) -> Result<Self, Error> {
89        let error = |reason: String| Error::Key(format!("{}: {reason}", path.display()));
90        #[cfg(unix)]
91        {
92            use std::os::unix::fs::PermissionsExt;
93            let mode = std::fs::metadata(path)
94                .map_err(|e| error(e.to_string()))?
95                .permissions()
96                .mode();
97            if mode & 0o077 != 0 {
98                return Err(error(format!("permissions {:o} are too open, use 600", mode & 0o777)));
99            }
100        }
101        let key = PrivateKey::read_openssh_file(path).map_err(|e| error(e.to_string()))?;
102        Self::new(key)
103    }
104
105    /// Uses an already loaded private key, which must not be encrypted.
106    pub fn new(key: PrivateKey) -> Result<Self, Error> {
107        if key.is_encrypted() {
108            return Err(Error::Key("encrypted keys are not supported".to_owned()));
109        }
110        Ok(Self { key })
111    }
112
113    /// The CA public key, to install in `TrustedUserCAKeys` on servers.
114    pub fn public_key(&self) -> &PublicKey {
115        self.key.public_key()
116    }
117
118    /// Signs a certificate for `key` granting exactly what `authorization`
119    /// allows.
120    ///
121    /// `key` must be the key the authorization was granted for, and the
122    /// authorization must be for certificate mode.
123    ///
124    /// `skew` backdates the start of validity to tolerate servers whose
125    /// clock is slightly behind; the certificate cannot be used before it
126    /// exists, so this never opens access earlier than authorized.
127    pub fn sign(&self, key: &PublicKey, authorization: &Authorization, skew: Duration) -> Result<Certificate, Error> {
128        let request = authorization.request();
129        if fingerprint(key) != request.public_key {
130            return Err(Error::Mismatch("the key differs from the authorized one"));
131        }
132        if request.mode != Mode::Certificate {
133            return Err(Error::Mismatch("the authorization is not for a certificate"));
134        }
135        self.certify(key, authorization, request.source_ip, skew)
136    }
137
138    /// Signs a certificate for the bastion's own `key`, to open the target
139    /// session of a relayed connection.
140    ///
141    /// The authorization must be for bastion mode. The certificate is pinned
142    /// to `bastion_ip`, the bastion's address as seen by targets, so it is
143    /// useless from anywhere else. `key` is not checked against the
144    /// authorization: the bastion generates it for this connection only.
145    pub fn sign_relayed(
146        &self,
147        key: &PublicKey,
148        authorization: &Authorization,
149        bastion_ip: IpAddr,
150        skew: Duration,
151    ) -> Result<Certificate, Error> {
152        if authorization.request().mode != Mode::Bastion {
153            return Err(Error::Mismatch("the authorization is not for the bastion"));
154        }
155        self.certify(key, authorization, bastion_ip, skew)
156    }
157
158    /// Signs what `authorization` allows, for connections from `source`.
159    fn certify(
160        &self,
161        key: &PublicKey,
162        authorization: &Authorization,
163        source: IpAddr,
164        skew: Duration,
165    ) -> Result<Certificate, Error> {
166        let request = authorization.request();
167        let valid_after = authorization.valid_from().saturating_sub(skew.as_secs());
168        let mut builder = Builder::new_with_random_nonce(
169            &mut OsRng,
170            key.key_data().clone(),
171            valid_after,
172            authorization.valid_until(),
173        )?;
174        builder
175            .serial(OsRng.next_u64())?
176            .cert_type(CertType::User)?
177            // sshd logs the key ID at login; the token ID can be added to the
178            // revocation list as is.
179            .key_id(authorization.token_id())?
180            .valid_principal(principal(&request.server, &request.user))?
181            .critical_option("source-address", Network::host(source).to_string())?
182            .extension("permit-pty", "")?;
183        if let Some(command) = &request.command {
184            builder.critical_option("force-command", command.as_str())?;
185        }
186        Ok(builder.sign(&self.key)?)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use std::time::SystemTime;
193
194    use biscuit_ssh_core::{AccessRequest, DEFAULT_POLICY, KeyPair, RevocationList, Verifier, mint};
195
196    use super::*;
197
198    const ENCRYPTED_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
199b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDcX8mjpz
200vbLHXF5VmOt5yiAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAILFBpn0wxSMAohR1
201nm+PR1EGu2mpjijaYFs4uUrU7LvzAAAAkJyPFegX9Q7hyYmeBFx49pzZTCosBLh0hJ3gbq
202sPNmfiQx5EI1/1YmkkugdNhmtOLWDGUhRfQAs5ZbzWaCpeR8iv2YcTRSbV+0rtkzZc3fDG
203ghr/lfWXu/ve8q2bHvIM1Wwl2+ITmZ4BO7cNQnACP5jkUwTNeFucHx5MkrGOsf2SvRAw6a
204k0rB3JgoZjw/ealQ==
205-----END OPENSSH PRIVATE KEY-----
206";
207
208    fn random_key() -> PrivateKey {
209        PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap()
210    }
211
212    fn ca() -> CertificateAuthority {
213        CertificateAuthority::new(random_key()).unwrap()
214    }
215
216    /// Authorizes `key` to log in as deploy on web-1 for 60 seconds.
217    fn authorize(key: &PublicKey, command: Option<&str>, mode: Mode) -> Authorization {
218        let root = KeyPair::new();
219        let token = mint(&root, &["web-1:deploy".parse().unwrap()], &[]).unwrap();
220        let request = AccessRequest {
221            time: SystemTime::now(),
222            source_ip: "::ffff:192.0.2.7".parse().unwrap(),
223            server: "web-1".parse().unwrap(),
224            user: "deploy".parse().unwrap(),
225            command: command.map(|c| c.parse().unwrap()),
226            public_key: fingerprint(key),
227            mode,
228        };
229        let verifier = Verifier::new(root.public(), DEFAULT_POLICY).unwrap();
230        verifier
231            .authorize(&token, &request, &RevocationList::default(), Duration::from_secs(60))
232            .unwrap()
233    }
234
235    #[test]
236    fn certificate_carries_every_constraint() {
237        let ca = ca();
238        let key = random_key();
239        let auth = authorize(key.public_key(), Some("uptime"), Mode::Certificate);
240        let cert = ca.sign(key.public_key(), &auth, Duration::from_secs(30)).unwrap();
241
242        cert.validate([&ca.public_key().fingerprint(HashAlg::Sha256)]).unwrap();
243        assert_eq!(cert.cert_type(), CertType::User);
244        assert_eq!(cert.valid_principals(), ["web-1:deploy"]);
245        assert_eq!(cert.key_id(), auth.token_id());
246        assert_eq!(cert.critical_options().get("source-address").unwrap(), "192.0.2.7/32");
247        assert_eq!(cert.critical_options().get("force-command").unwrap(), "uptime");
248        assert_eq!(cert.extensions().keys().collect::<Vec<_>>(), ["permit-pty"]);
249        assert_eq!(cert.valid_after(), auth.valid_from() - 30);
250        assert_eq!(cert.valid_before(), auth.valid_until());
251    }
252
253    #[test]
254    fn interactive_certificate_has_no_forced_command() {
255        let key = random_key();
256        let auth = authorize(key.public_key(), None, Mode::Certificate);
257        let cert = ca().sign(key.public_key(), &auth, Duration::ZERO).unwrap();
258        assert!(cert.critical_options().get("force-command").is_none());
259    }
260
261    #[test]
262    fn signs_only_what_was_authorized() {
263        let ca = ca();
264        let key = random_key();
265        let auth = authorize(key.public_key(), None, Mode::Certificate);
266        let other = random_key();
267        assert!(matches!(
268            ca.sign(other.public_key(), &auth, Duration::ZERO),
269            Err(Error::Mismatch(_))
270        ));
271
272        let bastion = authorize(key.public_key(), None, Mode::Bastion);
273        assert!(matches!(
274            ca.sign(key.public_key(), &bastion, Duration::ZERO),
275            Err(Error::Mismatch(_))
276        ));
277        let bastion_ip = "10.0.0.1".parse().unwrap();
278        assert!(matches!(
279            ca.sign_relayed(key.public_key(), &auth, bastion_ip, Duration::ZERO),
280            Err(Error::Mismatch(_))
281        ));
282    }
283
284    #[test]
285    fn relayed_certificate_is_pinned_to_the_bastion() {
286        let ca = ca();
287        let client_key = random_key();
288        let auth = authorize(client_key.public_key(), None, Mode::Bastion);
289        let bastion_key = random_key();
290        let bastion_ip = "10.0.0.1".parse().unwrap();
291        let cert = ca
292            .sign_relayed(bastion_key.public_key(), &auth, bastion_ip, Duration::ZERO)
293            .unwrap();
294        assert_eq!(cert.public_key(), bastion_key.public_key().key_data());
295        assert_eq!(cert.valid_principals(), ["web-1:deploy"]);
296        assert_eq!(cert.critical_options().get("source-address").unwrap(), "10.0.0.1/32");
297        assert_eq!(cert.valid_before(), auth.valid_until());
298    }
299
300    #[test]
301    fn refuses_certificates_as_public_keys() {
302        let key = random_key();
303        let auth = authorize(key.public_key(), None, Mode::Certificate);
304        let cert = ca().sign(key.public_key(), &auth, Duration::ZERO).unwrap();
305        assert!(parse_public_key(&cert.to_openssh().unwrap()).is_err());
306        assert!(parse_public_key(&key.public_key().to_openssh().unwrap()).is_ok());
307        assert!(parse_public_key("ssh-ed25519 garbage").is_err());
308    }
309
310    #[cfg(unix)]
311    #[test]
312    fn loads_only_private_unencrypted_keys() {
313        use std::os::unix::fs::PermissionsExt;
314
315        let dir = std::env::temp_dir().join(format!("biscuit-ssh-ca-{}", std::process::id()));
316        std::fs::create_dir_all(&dir).unwrap();
317        let write = |name: &str, content: &str, mode: u32| {
318            let path = dir.join(name);
319            std::fs::write(&path, content).unwrap();
320            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
321            path
322        };
323        let pem = random_key().to_openssh(ssh_key::LineEnding::LF).unwrap();
324
325        assert!(CertificateAuthority::load(&write("ok", &pem, 0o600)).is_ok());
326        for (name, content, mode) in [
327            ("open", pem.as_str(), 0o644),
328            ("malformed", "not a key", 0o600),
329            ("encrypted", ENCRYPTED_KEY, 0o600),
330        ] {
331            let result = CertificateAuthority::load(&write(name, content, mode));
332            assert!(matches!(result, Err(Error::Key(_))), "{name}");
333        }
334        assert!(CertificateAuthority::load(&dir.join("missing")).is_err());
335        std::fs::remove_dir_all(dir).unwrap();
336    }
337}