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

            
6
use tor_error::internal;
7
use tor_key_forge::{ErasedKey, KeyType, SshKeyAlgorithm, SshKeyData};
8

            
9
use crate::Result;
10
use crate::keystore::arti::err::ArtiNativeKeystoreError;
11

            
12
use std::path::PathBuf;
13
use 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.
21
pub(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`].
29
macro_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
18
        let key = $parse_fn(&*$key.inner).map_err(|e| {
48
18
            ArtiNativeKeystoreError::SshKeyParse {
49
18
                // TODO: rust thinks this clone is necessary because key.path is also used below (but
50
18
                // if we get to this point, we're going to return an error and never reach the other
51
18
                // error handling branches where we use key.path).
52
18
                path: $key.path.clone(),
53
18
                key_type: $key_type.clone().clone(),
54
18
                err: e.into(),
55
18
            }
56
18
        })?;
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.
73
25070
fn ssh_algorithm(key_type: &KeyType) -> Result<SshKeyAlgorithm> {
74
25070
    match key_type {
75
8050
        KeyType::Ed25519Keypair | KeyType::Ed25519PublicKey => Ok(SshKeyAlgorithm::Ed25519),
76
948
        KeyType::X25519StaticKeypair | KeyType::X25519PublicKey => Ok(SshKeyAlgorithm::X25519),
77
16072
        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
25070
}
84

            
85
impl UnparsedOpenSshKey {
86
    /// Create a new [`UnparsedOpenSshKey`].
87
    ///
88
    /// The contents of `inner` are erased on drop.
89
25088
    pub(crate) fn new(inner: String, path: PathBuf) -> Self {
90
25088
        Self {
91
25088
            inner: Zeroizing::new(inner),
92
25088
            path,
93
25088
        }
94
25088
    }
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
25088
    pub(crate) fn parse_ssh_format_erased(self, key_type: &KeyType) -> Result<ErasedKey> {
101
25088
        match key_type {
102
            KeyType::Ed25519Keypair
103
            | KeyType::X25519StaticKeypair
104
            | KeyType::Ed25519ExpandedKeypair
105
21114
            | KeyType::RsaKeypair => Ok(parse_openssh!(PRIVATE self, key_type).into_erased()?),
106
            KeyType::Ed25519PublicKey | KeyType::X25519PublicKey | KeyType::RsaPublicKey => {
107
3974
                Ok(parse_openssh!(PUBLIC self, key_type).into_erased()?)
108
            }
109
            &_ => Err(ArtiNativeKeystoreError::Bug(internal!("Unknown SSH key type")).into()),
110
        }
111
25088
    }
112
}
113

            
114
#[cfg(test)]
115
mod 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
    //! <!-- @@ 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 successfully 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
}