Skip to main content

tor_keymgr/keystore/arti/
ssh.rs

1//! Traits for converting keys to and from OpenSSH format.
2//
3// TODO #902: OpenSSH keys can have passphrases. While the current implementation isn't able to
4// handle such keys, we will eventually need to support them (this will be a breaking API change).
5
6use tor_error::internal;
7use tor_key_forge::{ErasedKey, KeyType, SshKeyAlgorithm, SshKeyData};
8
9use crate::Result;
10use crate::keystore::arti::err::ArtiNativeKeystoreError;
11
12use std::path::PathBuf;
13use zeroize::Zeroizing;
14
15/// An unparsed OpenSSH key.
16///
17/// Note: This is a wrapper around the contents of a file we think is an OpenSSH key. The inner
18/// value is unchecked/unvalidated, and might not actually be a valid OpenSSH key.
19///
20/// The inner value is zeroed on drop.
21pub(super) struct UnparsedOpenSshKey {
22    /// The contents of an OpenSSH key file.
23    inner: Zeroizing<String>,
24    /// The path of the file (for error reporting).
25    path: PathBuf,
26}
27
28/// Parse an OpenSSH key, returning its corresponding [`SshKeyData`].
29macro_rules! parse_openssh {
30    (PRIVATE $key:expr, $key_type:expr) => {{
31        SshKeyData::try_from_keypair_data(parse_openssh!(
32            $key,
33            $key_type,
34            ssh_key::private::PrivateKey::from_openssh
35        ).key_data().clone())?
36    }};
37
38    (PUBLIC $key:expr, $key_type:expr) => {{
39        SshKeyData::try_from_key_data(parse_openssh!(
40            $key,
41            $key_type,
42            ssh_key::public::PublicKey::from_openssh
43        ).key_data().clone())?
44    }};
45
46    ($key:expr, $key_type:expr, $parse_fn:path) => {{
47        let key = $parse_fn(&*$key.inner).map_err(|e| {
48            ArtiNativeKeystoreError::SshKeyParse {
49                // TODO: rust thinks this clone is necessary because key.path is also used below (but
50                // if we get to this point, we're going to return an error and never reach the other
51                // error handling branches where we use key.path).
52                path: $key.path.clone(),
53                key_type: $key_type.clone().clone(),
54                err: e.into(),
55            }
56        })?;
57
58        let wanted_key_algo = ssh_algorithm($key_type)?;
59
60        if SshKeyAlgorithm::from(key.algorithm()) != wanted_key_algo {
61            return Err(ArtiNativeKeystoreError::UnexpectedSshKeyType {
62                path: $key.path,
63                wanted_key_algo,
64                found_key_algo: key.algorithm().into(),
65            }.into());
66        }
67
68        key
69    }};
70}
71
72/// Get the algorithm of this key type.
73fn ssh_algorithm(key_type: &KeyType) -> Result<SshKeyAlgorithm> {
74    match key_type {
75        KeyType::Ed25519Keypair | KeyType::Ed25519PublicKey => Ok(SshKeyAlgorithm::Ed25519),
76        KeyType::X25519StaticKeypair | KeyType::X25519PublicKey => Ok(SshKeyAlgorithm::X25519),
77        KeyType::Ed25519ExpandedKeypair => Ok(SshKeyAlgorithm::Ed25519Expanded),
78        KeyType::RsaKeypair | KeyType::RsaPublicKey => Ok(SshKeyAlgorithm::Rsa),
79        &_ => {
80            Err(ArtiNativeKeystoreError::Bug(internal!("Unknown SSH key type {key_type:?}")).into())
81        }
82    }
83}
84
85impl UnparsedOpenSshKey {
86    /// Create a new [`UnparsedOpenSshKey`].
87    ///
88    /// The contents of `inner` are erased on drop.
89    pub(crate) fn new(inner: String, path: PathBuf) -> Self {
90        Self {
91            inner: Zeroizing::new(inner),
92            path,
93        }
94    }
95
96    /// Parse an OpenSSH key, convert the key material into a known key type, and return the
97    /// type-erased value.
98    ///
99    /// The caller is expected to downcast the value returned to a concrete type.
100    pub(crate) fn parse_ssh_format_erased(self, key_type: &KeyType) -> Result<ErasedKey> {
101        match key_type {
102            KeyType::Ed25519Keypair
103            | KeyType::X25519StaticKeypair
104            | KeyType::Ed25519ExpandedKeypair
105            | KeyType::RsaKeypair => Ok(parse_openssh!(PRIVATE self, key_type).into_erased()?),
106            KeyType::Ed25519PublicKey | KeyType::X25519PublicKey | KeyType::RsaPublicKey => {
107                Ok(parse_openssh!(PUBLIC self, key_type).into_erased()?)
108            }
109            &_ => Err(ArtiNativeKeystoreError::Bug(internal!("Unknown SSH key type")).into()),
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    // @@ begin test lint list maintained by maint/add_warning @@
117    #![allow(clippy::bool_assert_comparison)]
118    #![allow(clippy::clone_on_copy)]
119    #![allow(clippy::dbg_macro)]
120    #![allow(clippy::mixed_attributes_style)]
121    #![allow(clippy::print_stderr)]
122    #![allow(clippy::print_stdout)]
123    #![allow(clippy::single_char_pattern)]
124    #![allow(clippy::unwrap_used)]
125    #![allow(clippy::unchecked_time_subtraction)]
126    #![allow(clippy::useless_vec)]
127    #![allow(clippy::needless_pass_by_value)]
128    #![allow(clippy::string_slice)] // See arti#2571
129    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
130
131    use crate::test_utils::ssh_keys::*;
132    use crate::test_utils::sshkeygen_ed25519_strings;
133
134    use tor_key_forge::{EncodableItem, KeystoreItem};
135    use tor_llcrypto::pk::{curve25519, ed25519};
136
137    use super::*;
138
139    /// Comments used for the various keys. Should be kept in sync with the comments
140    /// used in `maint/keygen-openssh-test/generate`, the fallback comment used in
141    /// `maint/keygen-openssh-test/src/main.rs: make_openssh_key! {}`, and the
142    /// comment used in `crate::test_utils::sshkeygen_ed25519_strings()`.
143    const ED25519_OPENSSH_COMMENT: &str = "armadillo@example.com";
144    const ED25519_EXPANDED_OPENSSH_COMMENT: &str = "armadillo@example.com";
145    const X25519_OPENSSH_COMMENT: &str = "test-key";
146    const ED25519_SSHKEYGEN_COMMENT: &str = "";
147
148    /// Convenience trait for getting the underlying bytes for key types.
149    trait ToBytes {
150        type Bytes;
151        fn to_bytes(&self) -> Self::Bytes;
152    }
153
154    impl ToBytes for ed25519::Keypair {
155        type Bytes = [u8; 32];
156        fn to_bytes(&self) -> Self::Bytes {
157            self.to_bytes()
158        }
159    }
160
161    impl ToBytes for ed25519::PublicKey {
162        type Bytes = [u8; 32];
163        fn to_bytes(&self) -> Self::Bytes {
164            self.to_bytes()
165        }
166    }
167
168    impl ToBytes for ed25519::ExpandedKeypair {
169        type Bytes = [u8; 64];
170        fn to_bytes(&self) -> Self::Bytes {
171            self.to_secret_key_bytes()
172        }
173    }
174
175    impl ToBytes for curve25519::StaticKeypair {
176        type Bytes = [u8; 32];
177        fn to_bytes(&self) -> Self::Bytes {
178            self.secret.to_bytes()
179        }
180    }
181
182    impl ToBytes for curve25519::PublicKey {
183        type Bytes = [u8; 32];
184        fn to_bytes(&self) -> Self::Bytes {
185            self.to_bytes()
186        }
187    }
188
189    /// In-memory mangling. Pass private or public ED25519 key.
190    fn mangle_ed25519(key: &mut String) {
191        if key.len() > 150 {
192            // private
193            key.replace_range(107..178, "hello");
194        } else {
195            // public
196            key.insert_str(12, "garbage");
197        }
198    }
199
200    /// This macro checks if the passed encoded key can be successfully parsed or not. For the
201    /// encoded<1> keys that are successfully parsed and decoded<2>, the decoded<2> keys are
202    /// re-encoded<3>, and these re-encoded<3> keys are re-decoded<4>. Then, it asserts that:
203    ///
204    /// * Encoded<1> and re-encoded<3> keys are the same.
205    /// * Decoded<2> and re-decoded<4> keys are the same.
206    macro_rules! test_parse_ssh_format_erased {
207        ($key_ty:tt, $key:expr, err = $expect_err:expr) => {{
208            let key_type = KeyType::$key_ty;
209            let key = UnparsedOpenSshKey::new($key.into(), PathBuf::from("/dummy/path"));
210            let err = key
211                .parse_ssh_format_erased(&key_type)
212                .map(|_| "<type erased key>")
213                .unwrap_err();
214
215            assert_eq!(err.to_string(), $expect_err);
216        }};
217
218        ($key_ty:tt, $enc1:expr, $expected_ty:path, $comment:expr) => {{
219            let enc1 = $enc1.trim();
220            let key_type = KeyType::$key_ty;
221            let key = UnparsedOpenSshKey::new(enc1.into(), PathBuf::from("/test/path"));
222            let erased_key = key.parse_ssh_format_erased(&key_type).unwrap();
223
224            let Ok(dec1) = erased_key.downcast::<$expected_ty>() else {
225                panic!("failed to downcast");
226            };
227
228            let keystore_item = EncodableItem::as_keystore_item(&*dec1).unwrap();
229            let enc2 = match keystore_item {
230                KeystoreItem::Key(key) => key.to_openssh_string($comment).unwrap(),
231                _ => panic!("unexpected keystore item type {keystore_item:?}"),
232            };
233            let enc2 = enc2.trim();
234
235            // TODO: From
236            // https://gitlab.torproject.org/tpo/core/arti/-/merge_requests/2873#note_3178959:
237            // > the problem is that the two keys have different checkint values. When a PrivateKey is
238            // > parsed, its checkint is saved, and used when reencoding the key, so technically the
239            // > checkints should be the same. However, arti only actually stores the underlying
240            // > KeypairData and not the actual PrivateKey, so in SshKeyData::to_openssh_string, we
241            // > create a brand new PrivateKey from that KeypairData, which winds up with a None
242            // > checkint. When that PrivateKey then gets serialized, the checkint is taken from
243            // > KeypairData::checkint, which isn't the same as the checkint ssh-keygen put in the
244            // > original key. It's a weird implementation detail, but technically not a bug.
245            match key_type {
246                KeyType::Ed25519Keypair |
247                KeyType::X25519StaticKeypair |
248                KeyType::Ed25519ExpandedKeypair => (),
249                _ => assert_eq!(enc1, enc2),
250            }
251
252            let key = UnparsedOpenSshKey::new(enc2.into(), PathBuf::from("/test/path"));
253            let erased_key = key.parse_ssh_format_erased(&key_type).unwrap();
254            let Ok(dec2) = erased_key.downcast::<$expected_ty>() else {
255                panic!("failed to downcast");
256            };
257
258            assert_eq!(dec1.to_bytes(), dec2.to_bytes());
259        }};
260    }
261
262    #[test]
263    fn wrong_key_type() {
264        let key_type = KeyType::Ed25519Keypair;
265        let key = UnparsedOpenSshKey::new(DSA_OPENSSH.into(), PathBuf::from("/test/path"));
266        let err = key
267            .parse_ssh_format_erased(&key_type)
268            .map(|_| "<type erased key>")
269            .unwrap_err();
270
271        assert_eq!(
272            err.to_string(),
273            format!(
274                "Unexpected OpenSSH key type: wanted {}, found {}",
275                SshKeyAlgorithm::Ed25519,
276                SshKeyAlgorithm::Dsa
277            )
278        );
279
280        test_parse_ssh_format_erased!(
281            Ed25519Keypair,
282            DSA_OPENSSH,
283            err = format!(
284                "Unexpected OpenSSH key type: wanted {}, found {}",
285                SshKeyAlgorithm::Ed25519,
286                SshKeyAlgorithm::Dsa
287            )
288        );
289    }
290
291    #[test]
292    fn invalid_ed25519_key() {
293        test_parse_ssh_format_erased!(
294            Ed25519Keypair,
295            ED25519_OPENSSH_BAD,
296            err = "Failed to parse OpenSSH with type Ed25519Keypair"
297        );
298
299        test_parse_ssh_format_erased!(
300            Ed25519Keypair,
301            ED25519_OPENSSH_BAD_PUB,
302            err = "Failed to parse OpenSSH with type Ed25519Keypair"
303        );
304
305        test_parse_ssh_format_erased!(
306            Ed25519PublicKey,
307            ED25519_OPENSSH_BAD_PUB,
308            err = "Failed to parse OpenSSH with type Ed25519PublicKey"
309        );
310
311        if let Ok((mut bad, mut bad_pub)) = sshkeygen_ed25519_strings() {
312            mangle_ed25519(&mut bad);
313            mangle_ed25519(&mut bad_pub);
314
315            test_parse_ssh_format_erased!(
316                Ed25519Keypair,
317                &bad,
318                err = "Failed to parse OpenSSH with type Ed25519Keypair"
319            );
320
321            test_parse_ssh_format_erased!(
322                Ed25519Keypair,
323                &bad_pub,
324                err = "Failed to parse OpenSSH with type Ed25519Keypair"
325            );
326
327            test_parse_ssh_format_erased!(
328                Ed25519PublicKey,
329                &bad_pub,
330                err = "Failed to parse OpenSSH with type Ed25519PublicKey"
331            );
332        }
333    }
334
335    #[test]
336    fn ed25519_key() {
337        test_parse_ssh_format_erased!(
338            Ed25519Keypair,
339            ED25519_OPENSSH,
340            ed25519::Keypair,
341            ED25519_OPENSSH_COMMENT
342        );
343        test_parse_ssh_format_erased!(
344            Ed25519PublicKey,
345            ED25519_OPENSSH_PUB,
346            ed25519::PublicKey,
347            ED25519_OPENSSH_COMMENT
348        );
349
350        if let Ok((enc1, enc1_pub)) = sshkeygen_ed25519_strings() {
351            test_parse_ssh_format_erased!(
352                Ed25519Keypair,
353                enc1,
354                ed25519::Keypair,
355                ED25519_SSHKEYGEN_COMMENT
356            );
357            test_parse_ssh_format_erased!(
358                Ed25519PublicKey,
359                enc1_pub,
360                ed25519::PublicKey,
361                ED25519_SSHKEYGEN_COMMENT
362            );
363        }
364    }
365
366    #[test]
367    fn invalid_expanded_ed25519_key() {
368        test_parse_ssh_format_erased!(
369            Ed25519ExpandedKeypair,
370            ED25519_EXPANDED_OPENSSH_BAD,
371            err = "Failed to parse OpenSSH with type Ed25519ExpandedKeypair"
372        );
373    }
374
375    #[test]
376    fn expanded_ed25519_key() {
377        test_parse_ssh_format_erased!(
378            Ed25519ExpandedKeypair,
379            ED25519_EXPANDED_OPENSSH,
380            ed25519::ExpandedKeypair,
381            ED25519_EXPANDED_OPENSSH_COMMENT
382        );
383
384        test_parse_ssh_format_erased!(
385            Ed25519PublicKey,
386            ED25519_EXPANDED_OPENSSH_PUB, // using ed25519-expanded for public keys doesn't make sense
387            err = "Failed to parse OpenSSH with type Ed25519PublicKey"
388        );
389    }
390
391    #[test]
392    fn x25519_key() {
393        test_parse_ssh_format_erased!(
394            X25519StaticKeypair,
395            X25519_OPENSSH,
396            curve25519::StaticKeypair,
397            X25519_OPENSSH_COMMENT
398        );
399
400        test_parse_ssh_format_erased!(
401            X25519PublicKey,
402            X25519_OPENSSH_PUB,
403            curve25519::PublicKey,
404            X25519_OPENSSH_COMMENT
405        );
406    }
407
408    #[test]
409    fn invalid_x25519_key() {
410        test_parse_ssh_format_erased!(
411            X25519StaticKeypair,
412            X25519_OPENSSH_UNKNOWN_ALGORITHM,
413            err = "Unexpected OpenSSH key type: wanted X25519, found pangolin@torproject.org"
414        );
415
416        test_parse_ssh_format_erased!(
417            X25519PublicKey,
418            X25519_OPENSSH_UNKNOWN_ALGORITHM, // Note: this is a private key
419            err = "Failed to parse OpenSSH with type X25519PublicKey"
420        );
421
422        test_parse_ssh_format_erased!(
423            X25519PublicKey,
424            X25519_OPENSSH_UNKNOWN_ALGORITHM_PUB,
425            err = "Unexpected OpenSSH key type: wanted X25519, found armadillo@torproject.org"
426        );
427    }
428}