Skip to main content

bitwarden_ssh/
import.rs

1use ed25519;
2use pem_rfc7468::PemLabel;
3use pkcs8::{DecodePrivateKey, PrivateKeyInfo, SecretDocument, der::Decode, pkcs5};
4use ssh_key::{
5    Algorithm,
6    private::{Ed25519Keypair, RsaKeypair},
7    sec1,
8};
9
10use crate::{SshKeyData, error::SshKeyImportError, ssh_private_key_to_data};
11
12/// Import a PKCS8 or OpenSSH encoded private key, and returns a decoded [SshKeyData],
13/// with the public key and fingerprint, and the private key in OpenSSH format.
14/// A password can be provided for encrypted keys.
15/// # Returns
16/// - [SshKeyImportError::PasswordRequired] if the key is encrypted and no password is provided
17/// - [SshKeyImportError::WrongPassword] if the password provided is incorrect
18/// - [SshKeyImportError::UnsupportedKeyType] if the key type is not supported
19/// - [SshKeyImportError::Parsing] if the key is otherwise malformed and cannot be parsed
20pub fn import_key(
21    encoded_key: String,
22    password: Option<String>,
23) -> Result<SshKeyData, SshKeyImportError> {
24    let label = pem_rfc7468::decode_label(encoded_key.as_bytes())
25        .map_err(|_| SshKeyImportError::Parsing)?;
26
27    match label {
28        pkcs8::PrivateKeyInfo::<(), (), ()>::PEM_LABEL => import_pkcs8_key(encoded_key, None),
29        pkcs8::EncryptedPrivateKeyInfo::<()>::PEM_LABEL => import_pkcs8_key(
30            encoded_key,
31            Some(password.ok_or(SshKeyImportError::PasswordRequired)?),
32        ),
33        ssh_key::PrivateKey::PEM_LABEL => import_openssh_key(encoded_key, password),
34        _ => Err(SshKeyImportError::UnsupportedKeyType),
35    }
36}
37
38fn import_pkcs8_key(
39    encoded_key: String,
40    password: Option<String>,
41) -> Result<SshKeyData, SshKeyImportError> {
42    match parse_pkcs8_pem(&encoded_key, password.as_deref()) {
43        // Some exporters (e.g. 1Password's 1PUX) emit the base64 body on a single line, which the
44        // strict RFC 7468 parser rejects. Re-wrap to 64-character lines and retry once. Only
45        // `Parsing` failures are retried, so keys that import successfully today are unaffected.
46        Err(SshKeyImportError::Parsing) => {
47            let rewrapped = rewrap_pem(&encoded_key).ok_or(SshKeyImportError::Parsing)?;
48            parse_pkcs8_pem(&rewrapped, password.as_deref())
49        }
50        result => result,
51    }
52}
53
54fn parse_pkcs8_pem(
55    encoded_key: &str,
56    password: Option<&str>,
57) -> Result<SshKeyData, SshKeyImportError> {
58    let doc = if let Some(password) = password {
59        SecretDocument::from_pkcs8_encrypted_pem(encoded_key, password.as_bytes()).map_err(
60            |err| match err {
61                pkcs8::Error::EncryptedPrivateKey(pkcs5::Error::DecryptFailed) => {
62                    SshKeyImportError::WrongPassword
63                }
64                _ => SshKeyImportError::Parsing,
65            },
66        )?
67    } else {
68        SecretDocument::from_pkcs8_pem(encoded_key).map_err(|_| SshKeyImportError::Parsing)?
69    };
70
71    import_pkcs8_der_key(doc.as_bytes())
72}
73
74/// Re-wrap the base64 body of a PEM document to 64-character lines.
75///
76/// The strict RFC 7468 parser requires the body wrapped at 64 characters, but some exporters emit
77/// it on a single line. Returns [None] if the input is not a single well-formed PEM block, in which
78/// case the caller keeps the original parse error.
79fn rewrap_pem(pem: &str) -> Option<String> {
80    let mut lines = pem.lines();
81
82    let header = lines
83        .by_ref()
84        .find(|line| line.starts_with("-----BEGIN "))?;
85
86    // Concatenate the body (whitespace stripped) until the closing boundary.
87    let mut body = String::new();
88    let mut footer = None;
89    for line in lines.by_ref() {
90        if line.starts_with("-----END ") {
91            footer = Some(line);
92            break;
93        }
94        body.extend(line.split_whitespace());
95    }
96    let footer = footer?;
97
98    let mut out = String::with_capacity(body.len() + body.len() / 64 + header.len() + 16);
99    out.push_str(header);
100    out.push('\n');
101    // Char-based chunking keeps this panic-free even if the (already-rejected) body is non-ASCII.
102    let mut chars = body.chars();
103    loop {
104        let chunk: String = chars.by_ref().take(64).collect();
105        if chunk.is_empty() {
106            break;
107        }
108        out.push_str(&chunk);
109        out.push('\n');
110    }
111    out.push_str(footer);
112    out.push('\n');
113
114    Some(out)
115}
116
117/// Import a DER encoded private key, and returns a decoded [SshKeyData]. This is primarily used for
118/// importing SSH keys from other Credential Managers through Credential Exchange.
119pub fn import_pkcs8_der_key(encoded_key: &[u8]) -> Result<SshKeyData, SshKeyImportError> {
120    let private_key_info =
121        PrivateKeyInfo::from_der(encoded_key).map_err(|_| SshKeyImportError::Parsing)?;
122
123    let private_key = match private_key_info.algorithm.oid {
124        ed25519::pkcs8::ALGORITHM_OID => {
125            let private_key: ed25519::KeypairBytes = private_key_info
126                .try_into()
127                .map_err(|_| SshKeyImportError::Parsing)?;
128
129            ssh_key::private::PrivateKey::from(Ed25519Keypair::from(&private_key.secret_key.into()))
130        }
131        rsa::pkcs1::ALGORITHM_OID => {
132            let private_key: rsa::RsaPrivateKey = private_key_info
133                .try_into()
134                .map_err(|_| SshKeyImportError::Parsing)?;
135
136            ssh_key::private::PrivateKey::from(
137                RsaKeypair::try_from(private_key).map_err(|_| SshKeyImportError::Parsing)?,
138            )
139        }
140        sec1::ALGORITHM_OID => import_ecdsa_pkcs8_der(encoded_key)?,
141        _ => return Err(SshKeyImportError::UnsupportedKeyType),
142    };
143
144    ssh_private_key_to_data(private_key).map_err(|_| SshKeyImportError::Parsing)
145}
146
147fn import_openssh_key(
148    encoded_key: String,
149    password: Option<String>,
150) -> Result<SshKeyData, SshKeyImportError> {
151    let private_key =
152        ssh_key::private::PrivateKey::from_openssh(&encoded_key).map_err(|err| match err {
153            ssh_key::Error::AlgorithmUnknown | ssh_key::Error::AlgorithmUnsupported { .. } => {
154                SshKeyImportError::UnsupportedKeyType
155            }
156            _ => SshKeyImportError::Parsing,
157        })?;
158
159    if !is_supported_algorithm(&private_key.algorithm()) {
160        return Err(SshKeyImportError::UnsupportedKeyType);
161    }
162
163    let private_key = if private_key.is_encrypted() {
164        let password = password.ok_or(SshKeyImportError::PasswordRequired)?;
165        private_key
166            .decrypt(password.as_bytes())
167            .map_err(|_| SshKeyImportError::WrongPassword)?
168    } else {
169        private_key
170    };
171
172    ssh_private_key_to_data(private_key).map_err(|_| SshKeyImportError::Parsing)
173}
174
175/// True if a key of this algorithm can be used once it is in the vault.
176fn is_supported_algorithm(algorithm: &Algorithm) -> bool {
177    matches!(
178        algorithm,
179        Algorithm::Ed25519 | Algorithm::Rsa { .. } | Algorithm::Ecdsa { .. }
180    )
181}
182
183fn import_ecdsa_pkcs8_der(encoded_key: &[u8]) -> Result<ssh_key::PrivateKey, SshKeyImportError> {
184    use pkcs8::DecodePrivateKey as _;
185
186    if let Ok(sk) = p256::SecretKey::from_pkcs8_der(encoded_key) {
187        let public_key = sk.public_key();
188        let keypair = ssh_key::private::EcdsaKeypair::NistP256 {
189            public: public_key.into(),
190            private: ssh_key::private::EcdsaPrivateKey::from(sk),
191        };
192        return ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Ecdsa(keypair), "")
193            .map_err(|_| SshKeyImportError::Parsing);
194    }
195    if let Ok(sk) = p384::SecretKey::from_pkcs8_der(encoded_key) {
196        let public_key = sk.public_key();
197        let keypair = ssh_key::private::EcdsaKeypair::NistP384 {
198            public: public_key.into(),
199            private: ssh_key::private::EcdsaPrivateKey::from(sk),
200        };
201        return ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Ecdsa(keypair), "")
202            .map_err(|_| SshKeyImportError::Parsing);
203    }
204    if let Ok(sk) = p521::SecretKey::from_pkcs8_der(encoded_key) {
205        let public_key = sk.public_key();
206        let keypair = ssh_key::private::EcdsaKeypair::NistP521 {
207            public: public_key.into(),
208            private: ssh_key::private::EcdsaPrivateKey::from(sk),
209        };
210        return ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Ecdsa(keypair), "")
211            .map_err(|_| SshKeyImportError::Parsing);
212    }
213    Err(SshKeyImportError::UnsupportedKeyType)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn import_key_ed25519_openssh_unencrypted() {
222        let private_key = include_str!("../resources/import/ed25519_openssh_unencrypted");
223        let public_key = include_str!("../resources/import/ed25519_openssh_unencrypted.pub").trim();
224        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
225        assert_eq!(result.public_key, public_key);
226    }
227
228    #[test]
229    fn import_key_ed25519_openssh_encrypted() {
230        let private_key = include_str!("../resources/import/ed25519_openssh_encrypted");
231        let public_key = include_str!("../resources/import/ed25519_openssh_encrypted.pub").trim();
232        let result = import_key(private_key.to_string(), Some("password".to_string())).unwrap();
233        assert_eq!(result.public_key, public_key);
234    }
235
236    #[test]
237    fn import_key_rsa_openssh_unencrypted() {
238        let private_key = include_str!("../resources/import/rsa_openssh_unencrypted");
239        let public_key = include_str!("../resources/import/rsa_openssh_unencrypted.pub").trim();
240        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
241        assert_eq!(result.public_key, public_key);
242    }
243
244    #[test]
245    fn import_key_rsa_openssh_encrypted() {
246        let private_key = include_str!("../resources/import/rsa_openssh_encrypted");
247        let public_key = include_str!("../resources/import/rsa_openssh_encrypted.pub").trim();
248        let result = import_key(private_key.to_string(), Some("password".to_string())).unwrap();
249        assert_eq!(result.public_key, public_key);
250    }
251
252    #[test]
253    fn import_key_ed25519_pkcs8_unencrypted() {
254        let private_key = include_str!("../resources/import/ed25519_pkcs8_unencrypted");
255        let public_key = include_str!("../resources/import/ed25519_pkcs8_unencrypted.pub")
256            .replace("testkey", "");
257        let public_key = public_key.trim();
258        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
259        assert_eq!(result.public_key, public_key);
260    }
261
262    #[test]
263    fn import_key_rsa_pkcs8_unencrypted() {
264        let private_key = include_str!("../resources/import/rsa_pkcs8_unencrypted");
265        // for whatever reason pkcs8 + rsa does not include the comment in the public key
266        let public_key =
267            include_str!("../resources/import/rsa_pkcs8_unencrypted.pub").replace("testkey", "");
268        let public_key = public_key.trim();
269        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
270        assert_eq!(result.public_key, public_key);
271    }
272
273    #[test]
274    fn import_key_rsa_pkcs8_encrypted() {
275        let private_key = include_str!("../resources/import/rsa_pkcs8_encrypted");
276        let public_key =
277            include_str!("../resources/import/rsa_pkcs8_encrypted.pub").replace("testkey", "");
278        let public_key = public_key.trim();
279        let result = import_key(private_key.to_string(), Some("password".to_string())).unwrap();
280        assert_eq!(result.public_key, public_key);
281    }
282
283    #[test]
284    fn import_key_ed25519_openssh_encrypted_wrong_password() {
285        let private_key = include_str!("../resources/import/ed25519_openssh_encrypted");
286        let result = import_key(private_key.to_string(), Some("wrongpassword".to_string()));
287        assert_eq!(result.unwrap_err(), SshKeyImportError::WrongPassword);
288    }
289
290    /// 1Password's 1PUX export re-encodes Ed25519 keys as PKCS#8 (`BEGIN PRIVATE KEY`) with the
291    /// whole base64 body on a single line. The strict RFC 7468 parser (`pem-rfc7468`) rejects this
292    /// which will result in SshKeyImportError::Parsing ("Failed to parse key")
293    /// https://github.com/bitwarden/clients/issues/20432
294    #[test]
295    fn import_key_ed25519_pkcs8_unencrypted_single_line() {
296        // the private key used below was created by modifying ed25519_pkcs8_unencrypted to match
297        // 1pux export format where key contents span a single line
298        let private_key =
299            include_str!("../resources/import/ed25519_pkcs8_1password_single_line_unencrypted");
300        let public_key = include_str!("../resources/import/ed25519_pkcs8_unencrypted.pub").trim();
301
302        let result = import_key(private_key.to_string(), None).unwrap();
303        assert_eq!(result.public_key, public_key);
304    }
305
306    #[test]
307    fn import_non_key_error() {
308        let result = import_key("not a key".to_string(), Some("".to_string()));
309        assert_eq!(result.unwrap_err(), SshKeyImportError::Parsing);
310    }
311
312    #[test]
313    fn import_wrong_label_error() {
314        let private_key = include_str!("../resources/import/wrong_label");
315        let result = import_key(private_key.to_string(), Some("".to_string()));
316        assert_eq!(result.unwrap_err(), SshKeyImportError::UnsupportedKeyType);
317    }
318
319    #[test]
320    fn import_ecdsa_p256_openssh_unencrypted() {
321        let private_key = include_str!("../resources/import/ecdsa_openssh_unencrypted");
322        let public_key = include_str!("../resources/import/ecdsa_openssh_unencrypted.pub").trim();
323        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
324        assert_eq!(result.public_key, public_key);
325    }
326
327    #[test]
328    fn import_ecdsa_p384_openssh_unencrypted() {
329        let private_key = include_str!("../resources/import/ecdsa_p384_openssh_unencrypted");
330        let public_key =
331            include_str!("../resources/import/ecdsa_p384_openssh_unencrypted.pub").trim();
332        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
333        assert_eq!(result.public_key, public_key);
334    }
335
336    #[test]
337    fn import_ecdsa_p521_openssh_unencrypted() {
338        let private_key = include_str!("../resources/import/ecdsa_p521_openssh_unencrypted");
339        let public_key =
340            include_str!("../resources/import/ecdsa_p521_openssh_unencrypted.pub").trim();
341        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
342        assert_eq!(result.public_key, public_key);
343    }
344
345    #[test]
346    fn import_key_ed25519_putty() {
347        let private_key = include_str!("../resources/import/ed25519_putty_openssh_unencrypted");
348        let public_key =
349            include_str!("../resources/import/ed25519_putty_openssh_unencrypted.pub").trim();
350        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
351        assert_eq!(result.public_key, public_key);
352    }
353
354    #[test]
355    fn import_key_rsa_openssh_putty() {
356        let private_key = include_str!("../resources/import/rsa_putty_openssh_unencrypted");
357        let public_key =
358            include_str!("../resources/import/rsa_putty_openssh_unencrypted.pub").trim();
359        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
360        assert_eq!(result.public_key, public_key);
361    }
362
363    #[test]
364    fn import_key_rsa_pkcs8_putty() {
365        let private_key = include_str!("../resources/import/rsa_putty_pkcs1_unencrypted");
366        let result = import_key(private_key.to_string(), Some("".to_string()));
367        assert_eq!(result.unwrap_err(), SshKeyImportError::UnsupportedKeyType);
368    }
369
370    #[test]
371    fn import_ed25519_key_regression_17028() {
372        // https://github.com/bitwarden/clients/issues/17028#issuecomment-3455975763
373        let private_key = include_str!("../resources/import/ed25519_regression_17028");
374        let public_key = include_str!("../resources/import/ed25519_regression_17028.pub").trim();
375        let result = import_key(private_key.to_string(), None).unwrap();
376        assert_eq!(result.public_key, public_key);
377    }
378
379    #[test]
380    fn import_sk_ed25519_openssh_unencrypted_is_unsupported() {
381        let private_key = include_str!("../resources/import/sk_ed25519_openssh_unencrypted");
382        let result = import_key(private_key.to_string(), None);
383        assert_eq!(result.unwrap_err(), SshKeyImportError::UnsupportedKeyType);
384    }
385
386    #[test]
387    fn supported_algorithms_are_accepted() {
388        for algorithm in [
389            Algorithm::Ed25519,
390            Algorithm::Rsa { hash: None },
391            Algorithm::Ecdsa {
392                curve: ssh_key::EcdsaCurve::NistP256,
393            },
394            Algorithm::Ecdsa {
395                curve: ssh_key::EcdsaCurve::NistP384,
396            },
397            Algorithm::Ecdsa {
398                curve: ssh_key::EcdsaCurve::NistP521,
399            },
400        ] {
401            assert!(
402                is_supported_algorithm(&algorithm),
403                "{algorithm:?} should be supported"
404            );
405        }
406    }
407
408    #[test]
409    fn unsupported_algorithms_are_rejected() {
410        for algorithm in [
411            Algorithm::Dsa,
412            Algorithm::SkEd25519,
413            Algorithm::SkEcdsaSha2NistP256,
414            Algorithm::new("[email protected]").expect("unknown names map to Algorithm::Other"),
415        ] {
416            assert!(
417                !is_supported_algorithm(&algorithm),
418                "{algorithm:?} should not be supported"
419            );
420        }
421    }
422}