1#![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#[derive(Debug, thiserror::Error)]
37pub enum Error {
38 #[error("unusable CA key: {0}")]
40 Key(String),
41 #[error("unsupported public key: {0}")]
43 PublicKey(String),
44 #[error("refused: {0}")]
46 Mismatch(&'static str),
47 #[error("cannot sign certificate: {0}")]
49 Signing(#[from] ssh_key::Error),
50}
51
52pub fn principal(server: &Name, user: &Name) -> String {
58 format!("{server}:{user}")
59}
60
61pub 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
73pub fn fingerprint(key: &PublicKey) -> String {
75 key.fingerprint(HashAlg::Sha256).to_string()
76}
77
78pub struct CertificateAuthority {
80 key: PrivateKey,
81}
82
83impl CertificateAuthority {
84 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 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 pub fn public_key(&self) -> &PublicKey {
115 self.key.public_key()
116 }
117
118 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 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 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 .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 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}