Skip to main content

bitwarden_ssh/
import.rs

1use bitwarden_vault::SshKeyView;
2use ed25519;
3use pem_rfc7468::PemLabel;
4use pkcs8::{DecodePrivateKey, PrivateKeyInfo, SecretDocument, der::Decode, pkcs5};
5use ssh_key::private::{Ed25519Keypair, RsaKeypair};
6#[cfg(feature = "ecdsa-keys")]
7use ssh_key::sec1;
8
9use crate::{error::SshKeyImportError, ssh_private_key_to_view};
10
11/// Import a PKCS8 or OpenSSH encoded private key, and returns a decoded [SshKeyView],
12/// with the public key and fingerprint, and the private key in OpenSSH format.
13/// A password can be provided for encrypted keys.
14/// # Returns
15/// - [SshKeyImportError::PasswordRequired] if the key is encrypted and no password is provided
16/// - [SshKeyImportError::WrongPassword] if the password provided is incorrect
17/// - [SshKeyImportError::UnsupportedKeyType] if the key type is not supported
18/// - [SshKeyImportError::Parsing] if the key is otherwise malformed and cannot be parsed
19pub fn import_key(
20    encoded_key: String,
21    password: Option<String>,
22) -> Result<SshKeyView, SshKeyImportError> {
23    let label = pem_rfc7468::decode_label(encoded_key.as_bytes())
24        .map_err(|_| SshKeyImportError::Parsing)?;
25
26    match label {
27        pkcs8::PrivateKeyInfo::<(), (), ()>::PEM_LABEL => import_pkcs8_key(encoded_key, None),
28        pkcs8::EncryptedPrivateKeyInfo::<()>::PEM_LABEL => import_pkcs8_key(
29            encoded_key,
30            Some(password.ok_or(SshKeyImportError::PasswordRequired)?),
31        ),
32        ssh_key::PrivateKey::PEM_LABEL => import_openssh_key(encoded_key, password),
33        _ => Err(SshKeyImportError::UnsupportedKeyType),
34    }
35}
36
37fn import_pkcs8_key(
38    encoded_key: String,
39    password: Option<String>,
40) -> Result<SshKeyView, SshKeyImportError> {
41    match parse_pkcs8_pem(&encoded_key, password.as_deref()) {
42        // Some exporters (e.g. 1Password's 1PUX) emit the base64 body on a single line, which the
43        // strict RFC 7468 parser rejects. Re-wrap to 64-character lines and retry once. Only
44        // `Parsing` failures are retried, so keys that import successfully today are unaffected.
45        Err(SshKeyImportError::Parsing) => {
46            let rewrapped = rewrap_pem(&encoded_key).ok_or(SshKeyImportError::Parsing)?;
47            parse_pkcs8_pem(&rewrapped, password.as_deref())
48        }
49        result => result,
50    }
51}
52
53fn parse_pkcs8_pem(
54    encoded_key: &str,
55    password: Option<&str>,
56) -> Result<SshKeyView, SshKeyImportError> {
57    let doc = if let Some(password) = password {
58        SecretDocument::from_pkcs8_encrypted_pem(encoded_key, password.as_bytes()).map_err(
59            |err| match err {
60                pkcs8::Error::EncryptedPrivateKey(pkcs5::Error::DecryptFailed) => {
61                    SshKeyImportError::WrongPassword
62                }
63                _ => SshKeyImportError::Parsing,
64            },
65        )?
66    } else {
67        SecretDocument::from_pkcs8_pem(encoded_key).map_err(|_| SshKeyImportError::Parsing)?
68    };
69
70    import_pkcs8_der_key(doc.as_bytes())
71}
72
73/// Re-wrap the base64 body of a PEM document to 64-character lines.
74///
75/// The strict RFC 7468 parser requires the body wrapped at 64 characters, but some exporters emit
76/// it on a single line. Returns [None] if the input is not a single well-formed PEM block, in which
77/// case the caller keeps the original parse error.
78fn rewrap_pem(pem: &str) -> Option<String> {
79    let mut lines = pem.lines();
80
81    let header = lines
82        .by_ref()
83        .find(|line| line.starts_with("-----BEGIN "))?;
84
85    // Concatenate the body (whitespace stripped) until the closing boundary.
86    let mut body = String::new();
87    let mut footer = None;
88    for line in lines.by_ref() {
89        if line.starts_with("-----END ") {
90            footer = Some(line);
91            break;
92        }
93        body.extend(line.split_whitespace());
94    }
95    let footer = footer?;
96
97    let mut out = String::with_capacity(body.len() + body.len() / 64 + header.len() + 16);
98    out.push_str(header);
99    out.push('\n');
100    // Char-based chunking keeps this panic-free even if the (already-rejected) body is non-ASCII.
101    let mut chars = body.chars();
102    loop {
103        let chunk: String = chars.by_ref().take(64).collect();
104        if chunk.is_empty() {
105            break;
106        }
107        out.push_str(&chunk);
108        out.push('\n');
109    }
110    out.push_str(footer);
111    out.push('\n');
112
113    Some(out)
114}
115
116/// Import a DER encoded private key, and returns a decoded [SshKeyView]. This is primarily used for
117/// importing SSH keys from other Credential Managers through Credential Exchange.
118pub fn import_pkcs8_der_key(encoded_key: &[u8]) -> Result<SshKeyView, SshKeyImportError> {
119    let private_key_info =
120        PrivateKeyInfo::from_der(encoded_key).map_err(|_| SshKeyImportError::Parsing)?;
121
122    let private_key = match private_key_info.algorithm.oid {
123        ed25519::pkcs8::ALGORITHM_OID => {
124            let private_key: ed25519::KeypairBytes = private_key_info
125                .try_into()
126                .map_err(|_| SshKeyImportError::Parsing)?;
127
128            ssh_key::private::PrivateKey::from(Ed25519Keypair::from(&private_key.secret_key.into()))
129        }
130        rsa::pkcs1::ALGORITHM_OID => {
131            let private_key: rsa::RsaPrivateKey = private_key_info
132                .try_into()
133                .map_err(|_| SshKeyImportError::Parsing)?;
134
135            ssh_key::private::PrivateKey::from(
136                RsaKeypair::try_from(private_key).map_err(|_| SshKeyImportError::Parsing)?,
137            )
138        }
139        #[cfg(feature = "ecdsa-keys")]
140        sec1::ALGORITHM_OID => import_ecdsa_pkcs8_der(encoded_key)?,
141        _ => return Err(SshKeyImportError::UnsupportedKeyType),
142    };
143
144    ssh_private_key_to_view(private_key).map_err(|_| SshKeyImportError::Parsing)
145}
146
147fn import_openssh_key(
148    encoded_key: String,
149    password: Option<String>,
150) -> Result<SshKeyView, 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    let private_key = if private_key.is_encrypted() {
160        let password = password.ok_or(SshKeyImportError::PasswordRequired)?;
161        private_key
162            .decrypt(password.as_bytes())
163            .map_err(|_| SshKeyImportError::WrongPassword)?
164    } else {
165        private_key
166    };
167
168    reject_ecdsa_import(&private_key)?;
169
170    ssh_private_key_to_view(private_key).map_err(|_| SshKeyImportError::Parsing)
171}
172
173fn reject_ecdsa_import(key: &ssh_key::PrivateKey) -> Result<(), SshKeyImportError> {
174    #[cfg(not(feature = "ecdsa-keys"))]
175    if matches!(key.key_data(), ssh_key::private::KeypairData::Ecdsa(_)) {
176        return Err(SshKeyImportError::UnsupportedKeyType);
177    }
178    let _ = key;
179    Ok(())
180}
181
182#[cfg(feature = "ecdsa-keys")]
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    #[cfg(not(feature = "ecdsa-keys"))]
320    #[test]
321    fn import_ecdsa_blocked() {
322        let private_key = include_str!("../resources/import/ecdsa_openssh_unencrypted");
323        let result = import_key(private_key.to_string(), Some("".to_string()));
324        assert_eq!(result.unwrap_err(), SshKeyImportError::UnsupportedKeyType);
325    }
326
327    #[cfg(feature = "ecdsa-keys")]
328    #[test]
329    fn import_ecdsa_p256_openssh_unencrypted() {
330        let private_key = include_str!("../resources/import/ecdsa_openssh_unencrypted");
331        let public_key = include_str!("../resources/import/ecdsa_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    #[cfg(feature = "ecdsa-keys")]
337    #[test]
338    fn import_ecdsa_p384_openssh_unencrypted() {
339        let private_key = include_str!("../resources/import/ecdsa_p384_openssh_unencrypted");
340        let public_key =
341            include_str!("../resources/import/ecdsa_p384_openssh_unencrypted.pub").trim();
342        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
343        assert_eq!(result.public_key, public_key);
344    }
345
346    #[cfg(feature = "ecdsa-keys")]
347    #[test]
348    fn import_ecdsa_p521_openssh_unencrypted() {
349        let private_key = include_str!("../resources/import/ecdsa_p521_openssh_unencrypted");
350        let public_key =
351            include_str!("../resources/import/ecdsa_p521_openssh_unencrypted.pub").trim();
352        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
353        assert_eq!(result.public_key, public_key);
354    }
355
356    #[test]
357    fn import_key_ed25519_putty() {
358        let private_key = include_str!("../resources/import/ed25519_putty_openssh_unencrypted");
359        let public_key =
360            include_str!("../resources/import/ed25519_putty_openssh_unencrypted.pub").trim();
361        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
362        assert_eq!(result.public_key, public_key);
363    }
364
365    #[test]
366    fn import_key_rsa_openssh_putty() {
367        let private_key = include_str!("../resources/import/rsa_putty_openssh_unencrypted");
368        let public_key =
369            include_str!("../resources/import/rsa_putty_openssh_unencrypted.pub").trim();
370        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
371        assert_eq!(result.public_key, public_key);
372    }
373
374    #[test]
375    fn import_key_rsa_pkcs8_putty() {
376        let private_key = include_str!("../resources/import/rsa_putty_pkcs1_unencrypted");
377        let result = import_key(private_key.to_string(), Some("".to_string()));
378        assert_eq!(result.unwrap_err(), SshKeyImportError::UnsupportedKeyType);
379    }
380
381    #[test]
382    fn import_ed25519_key_regression_17028() {
383        // https://github.com/bitwarden/clients/issues/17028#issuecomment-3455975763
384        let private_key = include_str!("../resources/import/ed25519_regression_17028");
385        let public_key = include_str!("../resources/import/ed25519_regression_17028.pub").trim();
386        let result = import_key(private_key.to_string(), None).unwrap();
387        assert_eq!(result.public_key, public_key);
388    }
389}