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