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::{
6    private::{Ed25519Keypair, RsaKeypair},
7    sec1,
8};
9
10use crate::{error::SshKeyImportError, ssh_private_key_to_view};
11
12/// Import a PKCS8 or OpenSSH encoded private key, and returns a decoded [SshKeyView],
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<SshKeyView, 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<SshKeyView, 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<SshKeyView, 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 [SshKeyView]. 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<SshKeyView, 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_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    ssh_private_key_to_view(private_key).map_err(|_| SshKeyImportError::Parsing)
169}
170
171fn import_ecdsa_pkcs8_der(encoded_key: &[u8]) -> Result<ssh_key::PrivateKey, SshKeyImportError> {
172    use pkcs8::DecodePrivateKey as _;
173
174    if let Ok(sk) = p256::SecretKey::from_pkcs8_der(encoded_key) {
175        let public_key = sk.public_key();
176        let keypair = ssh_key::private::EcdsaKeypair::NistP256 {
177            public: public_key.into(),
178            private: ssh_key::private::EcdsaPrivateKey::from(sk),
179        };
180        return ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Ecdsa(keypair), "")
181            .map_err(|_| SshKeyImportError::Parsing);
182    }
183    if let Ok(sk) = p384::SecretKey::from_pkcs8_der(encoded_key) {
184        let public_key = sk.public_key();
185        let keypair = ssh_key::private::EcdsaKeypair::NistP384 {
186            public: public_key.into(),
187            private: ssh_key::private::EcdsaPrivateKey::from(sk),
188        };
189        return ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Ecdsa(keypair), "")
190            .map_err(|_| SshKeyImportError::Parsing);
191    }
192    if let Ok(sk) = p521::SecretKey::from_pkcs8_der(encoded_key) {
193        let public_key = sk.public_key();
194        let keypair = ssh_key::private::EcdsaKeypair::NistP521 {
195            public: public_key.into(),
196            private: ssh_key::private::EcdsaPrivateKey::from(sk),
197        };
198        return ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Ecdsa(keypair), "")
199            .map_err(|_| SshKeyImportError::Parsing);
200    }
201    Err(SshKeyImportError::UnsupportedKeyType)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn import_key_ed25519_openssh_unencrypted() {
210        let private_key = include_str!("../resources/import/ed25519_openssh_unencrypted");
211        let public_key = include_str!("../resources/import/ed25519_openssh_unencrypted.pub").trim();
212        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
213        assert_eq!(result.public_key, public_key);
214    }
215
216    #[test]
217    fn import_key_ed25519_openssh_encrypted() {
218        let private_key = include_str!("../resources/import/ed25519_openssh_encrypted");
219        let public_key = include_str!("../resources/import/ed25519_openssh_encrypted.pub").trim();
220        let result = import_key(private_key.to_string(), Some("password".to_string())).unwrap();
221        assert_eq!(result.public_key, public_key);
222    }
223
224    #[test]
225    fn import_key_rsa_openssh_unencrypted() {
226        let private_key = include_str!("../resources/import/rsa_openssh_unencrypted");
227        let public_key = include_str!("../resources/import/rsa_openssh_unencrypted.pub").trim();
228        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
229        assert_eq!(result.public_key, public_key);
230    }
231
232    #[test]
233    fn import_key_rsa_openssh_encrypted() {
234        let private_key = include_str!("../resources/import/rsa_openssh_encrypted");
235        let public_key = include_str!("../resources/import/rsa_openssh_encrypted.pub").trim();
236        let result = import_key(private_key.to_string(), Some("password".to_string())).unwrap();
237        assert_eq!(result.public_key, public_key);
238    }
239
240    #[test]
241    fn import_key_ed25519_pkcs8_unencrypted() {
242        let private_key = include_str!("../resources/import/ed25519_pkcs8_unencrypted");
243        let public_key = include_str!("../resources/import/ed25519_pkcs8_unencrypted.pub")
244            .replace("testkey", "");
245        let public_key = public_key.trim();
246        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
247        assert_eq!(result.public_key, public_key);
248    }
249
250    #[test]
251    fn import_key_rsa_pkcs8_unencrypted() {
252        let private_key = include_str!("../resources/import/rsa_pkcs8_unencrypted");
253        // for whatever reason pkcs8 + rsa does not include the comment in the public key
254        let public_key =
255            include_str!("../resources/import/rsa_pkcs8_unencrypted.pub").replace("testkey", "");
256        let public_key = public_key.trim();
257        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
258        assert_eq!(result.public_key, public_key);
259    }
260
261    #[test]
262    fn import_key_rsa_pkcs8_encrypted() {
263        let private_key = include_str!("../resources/import/rsa_pkcs8_encrypted");
264        let public_key =
265            include_str!("../resources/import/rsa_pkcs8_encrypted.pub").replace("testkey", "");
266        let public_key = public_key.trim();
267        let result = import_key(private_key.to_string(), Some("password".to_string())).unwrap();
268        assert_eq!(result.public_key, public_key);
269    }
270
271    #[test]
272    fn import_key_ed25519_openssh_encrypted_wrong_password() {
273        let private_key = include_str!("../resources/import/ed25519_openssh_encrypted");
274        let result = import_key(private_key.to_string(), Some("wrongpassword".to_string()));
275        assert_eq!(result.unwrap_err(), SshKeyImportError::WrongPassword);
276    }
277
278    /// 1Password's 1PUX export re-encodes Ed25519 keys as PKCS#8 (`BEGIN PRIVATE KEY`) with the
279    /// whole base64 body on a single line. The strict RFC 7468 parser (`pem-rfc7468`) rejects this
280    /// which will result in SshKeyImportError::Parsing ("Failed to parse key")
281    /// https://github.com/bitwarden/clients/issues/20432
282    #[test]
283    fn import_key_ed25519_pkcs8_unencrypted_single_line() {
284        // the private key used below was created by modifying ed25519_pkcs8_unencrypted to match
285        // 1pux export format where key contents span a single line
286        let private_key =
287            include_str!("../resources/import/ed25519_pkcs8_1password_single_line_unencrypted");
288        let public_key = include_str!("../resources/import/ed25519_pkcs8_unencrypted.pub").trim();
289
290        let result = import_key(private_key.to_string(), None).unwrap();
291        assert_eq!(result.public_key, public_key);
292    }
293
294    #[test]
295    fn import_non_key_error() {
296        let result = import_key("not a key".to_string(), Some("".to_string()));
297        assert_eq!(result.unwrap_err(), SshKeyImportError::Parsing);
298    }
299
300    #[test]
301    fn import_wrong_label_error() {
302        let private_key = include_str!("../resources/import/wrong_label");
303        let result = import_key(private_key.to_string(), Some("".to_string()));
304        assert_eq!(result.unwrap_err(), SshKeyImportError::UnsupportedKeyType);
305    }
306
307    #[test]
308    fn import_ecdsa_p256_openssh_unencrypted() {
309        let private_key = include_str!("../resources/import/ecdsa_openssh_unencrypted");
310        let public_key = include_str!("../resources/import/ecdsa_openssh_unencrypted.pub").trim();
311        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
312        assert_eq!(result.public_key, public_key);
313    }
314
315    #[test]
316    fn import_ecdsa_p384_openssh_unencrypted() {
317        let private_key = include_str!("../resources/import/ecdsa_p384_openssh_unencrypted");
318        let public_key =
319            include_str!("../resources/import/ecdsa_p384_openssh_unencrypted.pub").trim();
320        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
321        assert_eq!(result.public_key, public_key);
322    }
323
324    #[test]
325    fn import_ecdsa_p521_openssh_unencrypted() {
326        let private_key = include_str!("../resources/import/ecdsa_p521_openssh_unencrypted");
327        let public_key =
328            include_str!("../resources/import/ecdsa_p521_openssh_unencrypted.pub").trim();
329        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
330        assert_eq!(result.public_key, public_key);
331    }
332
333    #[test]
334    fn import_key_ed25519_putty() {
335        let private_key = include_str!("../resources/import/ed25519_putty_openssh_unencrypted");
336        let public_key =
337            include_str!("../resources/import/ed25519_putty_openssh_unencrypted.pub").trim();
338        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
339        assert_eq!(result.public_key, public_key);
340    }
341
342    #[test]
343    fn import_key_rsa_openssh_putty() {
344        let private_key = include_str!("../resources/import/rsa_putty_openssh_unencrypted");
345        let public_key =
346            include_str!("../resources/import/rsa_putty_openssh_unencrypted.pub").trim();
347        let result = import_key(private_key.to_string(), Some("".to_string())).unwrap();
348        assert_eq!(result.public_key, public_key);
349    }
350
351    #[test]
352    fn import_key_rsa_pkcs8_putty() {
353        let private_key = include_str!("../resources/import/rsa_putty_pkcs1_unencrypted");
354        let result = import_key(private_key.to_string(), Some("".to_string()));
355        assert_eq!(result.unwrap_err(), SshKeyImportError::UnsupportedKeyType);
356    }
357
358    #[test]
359    fn import_ed25519_key_regression_17028() {
360        // https://github.com/bitwarden/clients/issues/17028#issuecomment-3455975763
361        let private_key = include_str!("../resources/import/ed25519_regression_17028");
362        let public_key = include_str!("../resources/import/ed25519_regression_17028.pub").trim();
363        let result = import_key(private_key.to_string(), None).unwrap();
364        assert_eq!(result.public_key, public_key);
365    }
366}