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
12pub 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}