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::keystore::arti::err::ArtiNativeKeystoreError;
10use crate::Result;
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        &_ => {
79            Err(ArtiNativeKeystoreError::Bug(internal!("Unknown SSH key type {key_type:?}")).into())
80        }
81    }
82}
83
84impl UnparsedOpenSshKey {
85    /// Create a new [`UnparsedOpenSshKey`].
86    ///
87    /// The contents of `inner` are erased on drop.
88    pub(crate) fn new(inner: String, path: PathBuf) -> Self {
89        Self {
90            inner: Zeroizing::new(inner),
91            path,
92        }
93    }
94
95    /// Parse an OpenSSH key, convert the key material into a known key type, and return the
96    /// type-erased value.
97    ///
98    /// The caller is expected to downcast the value returned to a concrete type.
99    pub(crate) fn parse_ssh_format_erased(self, key_type: &KeyType) -> Result<ErasedKey> {
100        match key_type {
101            KeyType::Ed25519Keypair
102            | KeyType::X25519StaticKeypair
103            | KeyType::Ed25519ExpandedKeypair => {
104                Ok(parse_openssh!(PRIVATE self, key_type).into_erased()?)
105            }
106            KeyType::Ed25519PublicKey | KeyType::X25519PublicKey => {
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_duration_subtraction)]
126    #![allow(clippy::useless_vec)]
127    #![allow(clippy::needless_pass_by_value)]
128    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
129
130    use crate::test_utils::ssh_keys::*;
131    use crate::test_utils::sshkeygen_ed25519_strings;
132
133    use tor_key_forge::{EncodableItem, KeystoreItem};
134    use tor_llcrypto::pk::{curve25519, ed25519};
135
136    use super::*;
137
138    /// Comments used for the various keys. Should be kept in sync with the comments
139    /// used in `maint/keygen-openssh-test/generate`, the fallback comment used in
140    /// `maint/keygen-openssh-test/src/main.rs: make_openssh_key! {}`, and the
141    /// comment used in `crate::test_utils::sshkeygen_ed25519_strings()`.
142    const ED25519_OPENSSH_COMMENT: &str = "armadillo@example.com";
143    const ED25519_EXPANDED_OPENSSH_COMMENT: &str = "armadillo@example.com";
144    const X25519_OPENSSH_COMMENT: &str = "test-key";
145    const ED25519_SSHKEYGEN_COMMENT: &str = "";
146
147    /// Convenience trait for getting the underlying bytes for key types.
148    trait ToBytes {
149        type Bytes;
150        fn to_bytes(&self) -> Self::Bytes;
151    }
152
153    impl ToBytes for ed25519::Keypair {
154        type Bytes = [u8; 32];
155        fn to_bytes(&self) -> Self::Bytes {
156            self.to_bytes()
157        }
158    }
159
160    impl ToBytes for ed25519::PublicKey {
161        type Bytes = [u8; 32];
162        fn to_bytes(&self) -> Self::Bytes {
163            self.to_bytes()
164        }
165    }
166
167    impl ToBytes for ed25519::ExpandedKeypair {
168        type Bytes = [u8; 64];
169        fn to_bytes(&self) -> Self::Bytes {
170            self.to_secret_key_bytes()
171        }
172    }
173
174    impl ToBytes for curve25519::StaticKeypair {
175        type Bytes = [u8; 32];
176        fn to_bytes(&self) -> Self::Bytes {
177            self.secret.to_bytes()
178        }
179    }
180
181    impl ToBytes for curve25519::PublicKey {
182        type Bytes = [u8; 32];
183        fn to_bytes(&self) -> Self::Bytes {
184            self.to_bytes()
185        }
186    }
187
188    /// In-memory mangling. Pass private or public ED25519 key.
189    fn mangle_ed25519(key: &mut String) {
190        if key.len() > 150 {
191            // private
192            key.replace_range(107..178, "hello");
193        } else {
194            // public
195            key.insert_str(12, "garbage");
196        }
197    }
198
199    /// This macro checks if the passed encoded key can be successfully parsed or not. For the
200    /// encoded<1> keys that are sucessfully parsed and decoded<2>, the decoded<2> keys are
201    /// re-encoded<3>, and these re-encoded<3> keys are re-decoded<4>. Then, it asserts that:
202    ///
203    /// * Encoded<1> and re-encoded<3> keys are the same.
204    /// * Decoded<2> and re-decoded<4> keys are the same.
205    macro_rules! test_parse_ssh_format_erased {
206        ($key_ty:tt, $key:expr, err = $expect_err:expr) => {{
207            let key_type = KeyType::$key_ty;
208            let key = UnparsedOpenSshKey::new($key.into(), PathBuf::from("/dummy/path"));
209            let err = key
210                .parse_ssh_format_erased(&key_type)
211                .map(|_| "<type erased key>")
212                .unwrap_err();
213
214            assert_eq!(err.to_string(), $expect_err);
215        }};
216
217        ($key_ty:tt, $enc1:expr, $expected_ty:path, $comment:expr) => {{
218            let enc1 = $enc1.trim();
219            let key_type = KeyType::$key_ty;
220            let key = UnparsedOpenSshKey::new(enc1.into(), PathBuf::from("/test/path"));
221            let erased_key = key.parse_ssh_format_erased(&key_type).unwrap();
222
223            let Ok(dec1) = erased_key.downcast::<$expected_ty>() else {
224                panic!("failed to downcast");
225            };
226
227            let keystore_item = EncodableItem::as_keystore_item(&*dec1).unwrap();
228            let enc2 = match keystore_item {
229                KeystoreItem::Key(key) => key.to_openssh_string($comment).unwrap(),
230                _ => panic!("unexpected keystore item type {keystore_item:?}"),
231            };
232            let enc2 = enc2.trim();
233
234            // TODO: From
235            // https://gitlab.torproject.org/tpo/core/arti/-/merge_requests/2873#note_3178959:
236            // > the problem is that the two keys have different checkint values. When a PrivateKey is
237            // > parsed, its checkint is saved, and used when reencoding the key, so technically the
238            // > checkints should be the same. However, arti only actually stores the underlying
239            // > KeypairData and not the actual PrivateKey, so in SshKeyData::to_openssh_string, we
240            // > create a brand new PrivateKey from that KeypairData, which winds up with a None
241            // > checkint. When that PrivateKey then gets serialized, the checkint is taken from
242            // > KeypairData::checkint, which isn't the same as the checkint ssh-keygen put in the
243            // > original key. It's a weird implementation detail, but technically not a bug.
244            match key_type {
245                KeyType::Ed25519Keypair |
246                KeyType::X25519StaticKeypair |
247                KeyType::Ed25519ExpandedKeypair => (),
248                _ => assert_eq!(enc1, enc2),
249            }
250
251            let key = UnparsedOpenSshKey::new(enc2.into(), PathBuf::from("/test/path"));
252            let erased_key = key.parse_ssh_format_erased(&key_type).unwrap();
253            let Ok(dec2) = erased_key.downcast::<$expected_ty>() else {
254                panic!("failed to downcast");
255            };
256
257            assert_eq!(dec1.to_bytes(), dec2.to_bytes());
258        }};
259    }
260
261    #[test]
262    fn wrong_key_type() {
263        let key_type = KeyType::Ed25519Keypair;
264        let key = UnparsedOpenSshKey::new(DSA_OPENSSH.into(), PathBuf::from("/test/path"));
265        let err = key
266            .parse_ssh_format_erased(&key_type)
267            .map(|_| "<type erased key>")
268            .unwrap_err();
269
270        assert_eq!(
271            err.to_string(),
272            format!(
273                "Unexpected OpenSSH key type: wanted {}, found {}",
274                SshKeyAlgorithm::Ed25519,
275                SshKeyAlgorithm::Dsa
276            )
277        );
278
279        test_parse_ssh_format_erased!(
280            Ed25519Keypair,
281            DSA_OPENSSH,
282            err = format!(
283                "Unexpected OpenSSH key type: wanted {}, found {}",
284                SshKeyAlgorithm::Ed25519,
285                SshKeyAlgorithm::Dsa
286            )
287        );
288    }
289
290    #[test]
291    fn invalid_ed25519_key() {
292        test_parse_ssh_format_erased!(
293            Ed25519Keypair,
294            ED25519_OPENSSH_BAD,
295            err = "Failed to parse OpenSSH with type Ed25519Keypair"
296        );
297
298        test_parse_ssh_format_erased!(
299            Ed25519Keypair,
300            ED25519_OPENSSH_BAD_PUB,
301            err = "Failed to parse OpenSSH with type Ed25519Keypair"
302        );
303
304        test_parse_ssh_format_erased!(
305            Ed25519PublicKey,
306            ED25519_OPENSSH_BAD_PUB,
307            err = "Failed to parse OpenSSH with type Ed25519PublicKey"
308        );
309
310        if let Ok((mut bad, mut bad_pub)) = sshkeygen_ed25519_strings() {
311            mangle_ed25519(&mut bad);
312            mangle_ed25519(&mut bad_pub);
313
314            test_parse_ssh_format_erased!(
315                Ed25519Keypair,
316                &bad,
317                err = "Failed to parse OpenSSH with type Ed25519Keypair"
318            );
319
320            test_parse_ssh_format_erased!(
321                Ed25519Keypair,
322                &bad_pub,
323                err = "Failed to parse OpenSSH with type Ed25519Keypair"
324            );
325
326            test_parse_ssh_format_erased!(
327                Ed25519PublicKey,
328                &bad_pub,
329                err = "Failed to parse OpenSSH with type Ed25519PublicKey"
330            );
331        }
332    }
333
334    #[test]
335    fn ed25519_key() {
336        test_parse_ssh_format_erased!(
337            Ed25519Keypair,
338            ED25519_OPENSSH,
339            ed25519::Keypair,
340            ED25519_OPENSSH_COMMENT
341        );
342        test_parse_ssh_format_erased!(
343            Ed25519PublicKey,
344            ED25519_OPENSSH_PUB,
345            ed25519::PublicKey,
346            ED25519_OPENSSH_COMMENT
347        );
348
349        if let Ok((enc1, enc1_pub)) = sshkeygen_ed25519_strings() {
350            test_parse_ssh_format_erased!(
351                Ed25519Keypair,
352                enc1,
353                ed25519::Keypair,
354                ED25519_SSHKEYGEN_COMMENT
355            );
356            test_parse_ssh_format_erased!(
357                Ed25519PublicKey,
358                enc1_pub,
359                ed25519::PublicKey,
360                ED25519_SSHKEYGEN_COMMENT
361            );
362        }
363    }
364
365    #[test]
366    fn invalid_expanded_ed25519_key() {
367        test_parse_ssh_format_erased!(
368            Ed25519ExpandedKeypair,
369            ED25519_EXPANDED_OPENSSH_BAD,
370            err = "Failed to parse OpenSSH with type Ed25519ExpandedKeypair"
371        );
372    }
373
374    #[test]
375    fn expanded_ed25519_key() {
376        test_parse_ssh_format_erased!(
377            Ed25519ExpandedKeypair,
378            ED25519_EXPANDED_OPENSSH,
379            ed25519::ExpandedKeypair,
380            ED25519_EXPANDED_OPENSSH_COMMENT
381        );
382
383        test_parse_ssh_format_erased!(
384            Ed25519PublicKey,
385            ED25519_EXPANDED_OPENSSH_PUB, // using ed25519-expanded for public keys doesn't make sense
386            err = "Failed to parse OpenSSH with type Ed25519PublicKey"
387        );
388    }
389
390    #[test]
391    fn x25519_key() {
392        test_parse_ssh_format_erased!(
393            X25519StaticKeypair,
394            X25519_OPENSSH,
395            curve25519::StaticKeypair,
396            X25519_OPENSSH_COMMENT
397        );
398
399        test_parse_ssh_format_erased!(
400            X25519PublicKey,
401            X25519_OPENSSH_PUB,
402            curve25519::PublicKey,
403            X25519_OPENSSH_COMMENT
404        );
405    }
406
407    #[test]
408    fn invalid_x25519_key() {
409        test_parse_ssh_format_erased!(
410            X25519StaticKeypair,
411            X25519_OPENSSH_UNKNOWN_ALGORITHM,
412            err = "Unexpected OpenSSH key type: wanted X25519, found pangolin@torproject.org"
413        );
414
415        test_parse_ssh_format_erased!(
416            X25519PublicKey,
417            X25519_OPENSSH_UNKNOWN_ALGORITHM, // Note: this is a private key
418            err = "Failed to parse OpenSSH with type X25519PublicKey"
419        );
420
421        test_parse_ssh_format_erased!(
422            X25519PublicKey,
423            X25519_OPENSSH_UNKNOWN_ALGORITHM_PUB,
424            err = "Unexpected OpenSSH key type: wanted X25519, found armadillo@torproject.org"
425        );
426    }
427}