Skip to main content

uselesskey_ssh/
key.rs

1use std::fmt;
2use std::sync::Arc;
3
4use rand_chacha::ChaCha20Rng;
5use rand_chacha::rand_core::SeedableRng;
6use ssh_key::{Algorithm, LineEnding, PrivateKey};
7use uselesskey_core::Factory;
8
9use crate::SshSpec;
10
11/// Cache domain for SSH key fixtures.
12pub const DOMAIN_SSH_KEYPAIR: &str = "uselesskey:ssh:keypair";
13
14#[derive(Clone)]
15pub struct SshKeyPair {
16    label: String,
17    spec: SshSpec,
18    inner: Arc<Inner>,
19}
20
21struct Inner {
22    private_key_openssh: String,
23    public_key_openssh: String,
24}
25
26impl fmt::Debug for SshKeyPair {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        f.debug_struct("SshKeyPair")
29            .field("label", &self.label)
30            .field("spec", &self.spec)
31            .finish_non_exhaustive()
32    }
33}
34
35pub trait SshFactoryExt {
36    fn ssh_key(&self, label: impl AsRef<str>, spec: SshSpec) -> SshKeyPair;
37}
38
39impl SshFactoryExt for Factory {
40    fn ssh_key(&self, label: impl AsRef<str>, spec: SshSpec) -> SshKeyPair {
41        SshKeyPair::new(self, label.as_ref(), spec)
42    }
43}
44
45impl SshKeyPair {
46    fn new(factory: &Factory, label: &str, spec: SshSpec) -> Self {
47        let spec_bytes = spec.stable_bytes();
48        let inner = factory.get_or_init(DOMAIN_SSH_KEYPAIR, label, &spec_bytes, "good", |seed| {
49            let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
50            let private_key = PrivateKey::random(&mut rng, to_algorithm(spec))
51                .expect("SSH private key generation failed");
52            let public_key = private_key.public_key();
53
54            Inner {
55                private_key_openssh: private_key
56                    .to_openssh(LineEnding::LF)
57                    .expect("OpenSSH private key encoding failed")
58                    .to_string(),
59                public_key_openssh: public_key
60                    .to_openssh()
61                    .expect("OpenSSH public key encoding failed"),
62            }
63        });
64
65        Self {
66            label: label.to_string(),
67            spec,
68            inner,
69        }
70    }
71
72    pub fn label(&self) -> &str {
73        &self.label
74    }
75
76    pub fn spec(&self) -> SshSpec {
77        self.spec
78    }
79
80    /// OpenSSH private key text (`-----BEGIN OPENSSH PRIVATE KEY-----`).
81    pub fn private_key_openssh(&self) -> &str {
82        &self.inner.private_key_openssh
83    }
84
85    /// Public key line suitable for `authorized_keys`.
86    pub fn authorized_key_line(&self) -> &str {
87        &self.inner.public_key_openssh
88    }
89}
90
91fn to_algorithm(spec: SshSpec) -> Algorithm {
92    match spec {
93        SshSpec::Ed25519 => Algorithm::Ed25519,
94        SshSpec::Rsa => Algorithm::Rsa { hash: None },
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use ssh_key::{PrivateKey, PublicKey};
101    use uselesskey_core::Seed;
102
103    use super::*;
104
105    #[test]
106    fn deterministic_authorized_key_lines() {
107        let fx = Factory::deterministic(Seed::from_env_value("ssh-det-lines").unwrap());
108        let a = fx.ssh_key("deploy", SshSpec::ed25519());
109        let b = fx.ssh_key("deploy", SshSpec::ed25519());
110        assert_eq!(a.authorized_key_line(), b.authorized_key_line());
111    }
112
113    #[test]
114    fn round_trip_parse_private_and_public() {
115        let fx = Factory::random();
116        let k = fx.ssh_key("infra", SshSpec::rsa());
117
118        let parsed_private = PrivateKey::from_openssh(k.private_key_openssh()).unwrap();
119        let parsed_public = PublicKey::from_openssh(k.authorized_key_line()).unwrap();
120
121        assert_eq!(parsed_private.public_key(), &parsed_public);
122    }
123}