Skip to main content

uselesskey_ssh/
cert.rs

1use std::fmt;
2use std::sync::Arc;
3
4use rand_chacha::ChaCha20Rng;
5use rand_chacha::rand_core::SeedableRng;
6use ssh_key::certificate::{Builder, CertType};
7use ssh_key::{Algorithm, Certificate, LineEnding, PrivateKey};
8use uselesskey_core::Factory;
9
10use crate::{SshCertSpec, SshCertType};
11
12/// Cache domain for SSH certificate fixtures.
13pub const DOMAIN_SSH_CERT: &str = "uselesskey:ssh:cert";
14
15#[derive(Clone)]
16pub struct SshCertFixture {
17    label: String,
18    spec: SshCertSpec,
19    inner: Arc<Inner>,
20}
21
22struct Inner {
23    private_key_openssh: String,
24    certificate_openssh: String,
25}
26
27impl fmt::Debug for SshCertFixture {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        f.debug_struct("SshCertFixture")
30            .field("label", &self.label)
31            .field("spec", &self.spec)
32            .finish_non_exhaustive()
33    }
34}
35
36pub trait SshCertFactoryExt {
37    fn ssh_cert(&self, label: impl AsRef<str>, spec: SshCertSpec) -> SshCertFixture;
38}
39
40impl SshCertFactoryExt for Factory {
41    fn ssh_cert(&self, label: impl AsRef<str>, spec: SshCertSpec) -> SshCertFixture {
42        SshCertFixture::new(self, label.as_ref(), spec)
43    }
44}
45
46impl SshCertFixture {
47    fn new(factory: &Factory, label: &str, spec: SshCertSpec) -> Self {
48        let spec_bytes = spec.stable_bytes();
49        let inner = factory.get_or_init(DOMAIN_SSH_CERT, label, &spec_bytes, "good", |seed| {
50            let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
51
52            let ca_private =
53                PrivateKey::random(&mut rng, Algorithm::Ed25519).expect("SSH CA keygen failed");
54            let subject_private =
55                PrivateKey::random(&mut rng, Algorithm::Ed25519).expect("SSH keygen failed");
56
57            let mut builder = Builder::new(
58                seed.bytes()[..16].to_vec(),
59                subject_private.public_key().key_data().clone(),
60                spec.validity.valid_after,
61                spec.validity.valid_before,
62            )
63            .expect("invalid SSH certificate validity window");
64
65            builder.serial(0).expect("unable to set cert serial");
66            builder
67                .key_id(label.to_owned())
68                .expect("unable to set cert key_id");
69            builder
70                .cert_type(to_cert_type(spec.cert_type))
71                .expect("unable to set cert type");
72
73            for principal in &spec.principals {
74                builder
75                    .valid_principal(principal.clone())
76                    .expect("unable to add valid principal");
77            }
78            if spec.principals.is_empty() {
79                builder
80                    .all_principals_valid()
81                    .expect("unable to mark all principals valid");
82            }
83
84            for (name, value) in &spec.critical_options {
85                builder
86                    .critical_option(name.clone(), value.clone())
87                    .expect("unable to add critical option");
88            }
89            for (name, value) in &spec.extensions {
90                builder
91                    .extension(name.clone(), value.clone())
92                    .expect("unable to add extension");
93            }
94
95            let cert = builder.sign(&ca_private).expect("unable to sign SSH cert");
96
97            Inner {
98                private_key_openssh: subject_private
99                    .to_openssh(LineEnding::LF)
100                    .expect("OpenSSH private key encoding failed")
101                    .to_string(),
102                certificate_openssh: cert.to_openssh().expect("OpenSSH cert encoding failed"),
103            }
104        });
105
106        Self {
107            label: label.to_string(),
108            spec,
109            inner,
110        }
111    }
112
113    pub fn label(&self) -> &str {
114        &self.label
115    }
116
117    pub fn spec(&self) -> &SshCertSpec {
118        &self.spec
119    }
120
121    pub fn private_key_openssh(&self) -> &str {
122        &self.inner.private_key_openssh
123    }
124
125    pub fn certificate_openssh(&self) -> &str {
126        &self.inner.certificate_openssh
127    }
128
129    pub fn certificate(&self) -> Certificate {
130        Certificate::from_openssh(self.certificate_openssh()).expect("stored SSH cert must parse")
131    }
132}
133
134fn to_cert_type(cert_type: SshCertType) -> CertType {
135    match cert_type {
136        SshCertType::User => CertType::User,
137        SshCertType::Host => CertType::Host,
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use ssh_key::certificate::CertType;
144    use uselesskey_core::Seed;
145
146    use super::*;
147    use crate::SshValidity;
148
149    #[test]
150    fn cert_principals_and_validity_are_encoded() {
151        let fx = Factory::deterministic(Seed::from_env_value("ssh-cert-principals").unwrap());
152        let spec = SshCertSpec {
153            principals: vec!["deploy".to_string(), "ops".to_string()],
154            validity: SshValidity::new(1_700_000_000, 1_700_000_600),
155            cert_type: SshCertType::User,
156            critical_options: vec![("force-command".to_string(), "/usr/bin/true".to_string())],
157            extensions: vec![("permit-pty".to_string(), String::new())],
158        };
159
160        let cert = fx.ssh_cert("deploy-cert", spec).certificate();
161
162        assert_eq!(
163            cert.valid_principals(),
164            ["deploy".to_string(), "ops".to_string()]
165        );
166        assert_eq!(cert.valid_after(), 1_700_000_000);
167        assert_eq!(cert.valid_before(), 1_700_000_600);
168        assert_eq!(cert.cert_type(), CertType::User);
169        assert_eq!(
170            cert.critical_options()
171                .get("force-command")
172                .map(String::as_str),
173            Some("/usr/bin/true")
174        );
175        assert!(cert.extensions().contains_key("permit-pty"));
176    }
177
178    #[test]
179    fn cert_round_trip_parse() {
180        let fx = Factory::random();
181        let cert = fx
182            .ssh_cert(
183                "host-cert",
184                SshCertSpec::host(["host1.internal"], SshValidity::new(10, 20)),
185            )
186            .certificate();
187
188        let encoded = cert.to_openssh().unwrap();
189        let decoded = Certificate::from_openssh(&encoded).unwrap();
190
191        assert_eq!(decoded.valid_principals(), ["host1.internal".to_string()]);
192        assert_eq!(decoded.cert_type(), CertType::Host);
193    }
194}