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
19618
fn ssh_algorithm(key_type: &KeyType) -> Result<SshKeyAlgorithm> {
74
19618
    match key_type {
75
6202
        KeyType::Ed25519Keypair | KeyType::Ed25519PublicKey => Ok(SshKeyAlgorithm::Ed25519),
76
732
        KeyType::X25519StaticKeypair | KeyType::X25519PublicKey => Ok(SshKeyAlgorithm::X25519),
77
12684
        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
19618
}
84

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