Skip to main content

bitwarden_vault/cipher/
ssh_key.rs

1use bitwarden_api_api::models::CipherSshKeyModel;
2use bitwarden_core::{
3    key_management::{KeySlotIds, SymmetricKeySlotId},
4    require,
5};
6use bitwarden_crypto::{
7    CompositeEncryptable, CryptoError, Decryptable, EncString, KeyStoreContext,
8    PrimitiveEncryptable,
9};
10use serde::{Deserialize, Serialize};
11
12use super::cipher::CipherKind;
13use crate::{Cipher, VaultParseError, cipher::cipher::CopyableCipherFields};
14
15#[serde_with::skip_serializing_none]
16#[derive(Serialize, Deserialize, Debug, Clone)]
17#[serde(rename_all = "camelCase")]
18#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
19#[bitwarden_ffi::wasm_record]
20pub struct SshKey {
21    /// SSH private key (ed25519/rsa) in unencrypted openssh private key format [OpenSSH private key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key)
22    pub private_key: EncString,
23    /// SSH public key (ed25519/rsa) according to [RFC4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.6).
24    pub public_key: Option<EncString>,
25    /// SSH fingerprint using SHA256 in the format: `SHA256:BASE64_ENCODED_FINGERPRINT`.
26    pub fingerprint: Option<EncString>,
27}
28
29#[allow(missing_docs)]
30#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
31#[serde(rename_all = "camelCase", deny_unknown_fields)]
32#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
33#[bitwarden_ffi::wasm_record]
34pub struct SshKeyView {
35    /// SSH private key (ed25519/rsa) in unencrypted openssh private key format [OpenSSH private key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key)
36    pub private_key: String,
37    /// SSH public key (ed25519/rsa) according to [RFC4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.6)
38    pub public_key: String,
39    /// SSH fingerprint using SHA256 in the format: `SHA256:BASE64_ENCODED_FINGERPRINT`
40    pub fingerprint: String,
41}
42
43impl From<bitwarden_ssh::SshKeyData> for SshKeyView {
44    fn from(key: bitwarden_ssh::SshKeyData) -> Self {
45        SshKeyView {
46            private_key: key.private_key,
47            public_key: key.public_key,
48            fingerprint: key.fingerprint,
49        }
50    }
51}
52
53/// Derive the public key and fingerprint from an unencrypted OpenSSH private key.
54///
55/// Returns empty strings if the key cannot be parsed.
56fn derive_public_key_and_fingerprint(private_key: &str) -> (String, String) {
57    bitwarden_ssh::import::import_key(private_key.to_string(), None)
58        .map(|data| (data.public_key, data.fingerprint))
59        .unwrap_or_default()
60}
61
62impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, SshKey> for SshKeyView {
63    fn encrypt_composite(
64        &self,
65        ctx: &mut KeyStoreContext<KeySlotIds>,
66        key: SymmetricKeySlotId,
67    ) -> Result<SshKey, CryptoError> {
68        // Derive the public key/fingerprint from the private key when absent, so stored data is
69        // always complete.
70        let mut public_key = self.public_key.clone();
71        let mut fingerprint = self.fingerprint.clone();
72        if public_key.is_empty() || fingerprint.is_empty() {
73            let (derived_public_key, derived_fingerprint) =
74                derive_public_key_and_fingerprint(&self.private_key);
75            if public_key.is_empty() {
76                public_key = derived_public_key;
77            }
78            if fingerprint.is_empty() {
79                fingerprint = derived_fingerprint;
80            }
81        }
82
83        Ok(SshKey {
84            private_key: self.private_key.encrypt(ctx, key)?,
85            public_key: Some(public_key.encrypt(ctx, key)?),
86            fingerprint: Some(fingerprint.encrypt(ctx, key)?),
87        })
88    }
89}
90
91impl Decryptable<KeySlotIds, SymmetricKeySlotId, SshKeyView> for SshKey {
92    fn decrypt(
93        &self,
94        ctx: &mut KeyStoreContext<KeySlotIds>,
95        key: SymmetricKeySlotId,
96    ) -> Result<SshKeyView, CryptoError> {
97        let private_key: String = self.private_key.decrypt(ctx, key)?;
98
99        match (&self.public_key, &self.fingerprint) {
100            (Some(public_key), Some(fingerprint)) => Ok(SshKeyView {
101                private_key,
102                public_key: public_key.decrypt(ctx, key)?,
103                fingerprint: fingerprint.decrypt(ctx, key)?,
104            }),
105            // Derive both from the private key when either is absent.
106            _ => {
107                let (public_key, fingerprint) = derive_public_key_and_fingerprint(&private_key);
108                Ok(SshKeyView {
109                    private_key,
110                    public_key,
111                    fingerprint,
112                })
113            }
114        }
115    }
116}
117
118impl CipherKind for SshKey {
119    fn decrypt_subtitle(
120        &self,
121        ctx: &mut KeyStoreContext<KeySlotIds>,
122        key: SymmetricKeySlotId,
123    ) -> Result<String, CryptoError> {
124        match &self.fingerprint {
125            Some(fingerprint) => fingerprint.decrypt(ctx, key),
126            // Derive the fingerprint from the private key when it isn't stored.
127            None => {
128                let private_key: String = self.private_key.decrypt(ctx, key)?;
129                let (_, fingerprint) = derive_public_key_and_fingerprint(&private_key);
130                Ok(fingerprint)
131            }
132        }
133    }
134
135    fn get_copyable_fields(&self, _: Option<&Cipher>) -> Vec<CopyableCipherFields> {
136        [CopyableCipherFields::SshKey].into_iter().collect()
137    }
138}
139
140impl TryFrom<CipherSshKeyModel> for SshKey {
141    type Error = VaultParseError;
142
143    fn try_from(ssh_key: CipherSshKeyModel) -> Result<Self, Self::Error> {
144        Ok(Self {
145            private_key: require!(EncString::try_from_optional(ssh_key.private_key)?),
146            public_key: EncString::try_from_optional(ssh_key.public_key)?,
147            fingerprint: EncString::try_from_optional(ssh_key.key_fingerprint)?,
148        })
149    }
150}
151
152impl From<SshKey> for CipherSshKeyModel {
153    fn from(ssh_key: SshKey) -> Self {
154        Self {
155            private_key: Some(ssh_key.private_key.to_string()),
156            public_key: ssh_key.public_key.map(|e| e.to_string()),
157            key_fingerprint: ssh_key.fingerprint.map(|e| e.to_string()),
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use bitwarden_core::key_management::create_test_crypto_with_user_key;
165    use bitwarden_crypto::SymmetricCryptoKey;
166
167    use super::*;
168    use crate::cipher::cipher::CopyableCipherFields;
169
170    #[test]
171    fn test_subtitle_ssh_key() {
172        let key = SymmetricCryptoKey::try_from("hvBMMb1t79YssFZkpetYsM3deyVuQv4r88Uj9gvYe0+G8EwxvW3v1iywVmSl61iwzd17JW5C/ivzxSP2C9h7Tw==".to_string()).unwrap();
173        let key_store = create_test_crypto_with_user_key(key);
174        let key = SymmetricKeySlotId::User;
175        let mut ctx = key_store.context();
176
177        let original_subtitle = "SHA256:1JjFjvPRkj1Gbf2qRP1dgHiIzEuNAEvp+92x99jw3K0".to_string();
178        let fingerprint_encrypted = original_subtitle.to_owned().encrypt(&mut ctx, key).unwrap();
179        let private_key_encrypted = "".to_string().encrypt(&mut ctx, key).unwrap();
180        let public_key_encrypted = "".to_string().encrypt(&mut ctx, key).unwrap();
181
182        let ssh_key = SshKey {
183            private_key: private_key_encrypted,
184            public_key: Some(public_key_encrypted),
185            fingerprint: Some(fingerprint_encrypted),
186        };
187
188        assert_eq!(
189            ssh_key.decrypt_subtitle(&mut ctx, key).unwrap(),
190            original_subtitle
191        );
192    }
193
194    #[test]
195    fn test_deserialize_missing_public_key_and_fingerprint() {
196        // A cipher with only a private key deserializes with the missing fields as None.
197        let absent = r#"{"privateKey":"2.tMIugb6zQOL+EuOizna1wQ==|W5dDLoNJtajN68yeOjrr6w==|qS4hwJB0B0gNLI0o+jxn+sKMBmvtVgJCRYNEXBZoGeE="}"#;
198        let ssh_key: SshKey = serde_json::from_str(absent).unwrap();
199        assert!(ssh_key.public_key.is_none());
200        assert!(ssh_key.fingerprint.is_none());
201
202        let null = r#"{"privateKey":"2.tMIugb6zQOL+EuOizna1wQ==|W5dDLoNJtajN68yeOjrr6w==|qS4hwJB0B0gNLI0o+jxn+sKMBmvtVgJCRYNEXBZoGeE=","publicKey":null,"fingerprint":null}"#;
203        let ssh_key: SshKey = serde_json::from_str(null).unwrap();
204        assert!(ssh_key.public_key.is_none());
205        assert!(ssh_key.fingerprint.is_none());
206    }
207
208    #[test]
209    fn test_decrypt_unparseable_private_key_falls_back_to_empty() {
210        // An absent public key/fingerprint with an unparseable private key decrypts to empty.
211        let key = SymmetricCryptoKey::try_from("hvBMMb1t79YssFZkpetYsM3deyVuQv4r88Uj9gvYe0+G8EwxvW3v1iywVmSl61iwzd17JW5C/ivzxSP2C9h7Tw==".to_string()).unwrap();
212        let key_store = create_test_crypto_with_user_key(key);
213        let key = SymmetricKeySlotId::User;
214        let mut ctx = key_store.context();
215
216        let ssh_key = SshKey {
217            private_key: "the-private-key"
218                .to_string()
219                .encrypt(&mut ctx, key)
220                .unwrap(),
221            public_key: None,
222            fingerprint: None,
223        };
224
225        let view = ssh_key.decrypt(&mut ctx, key).unwrap();
226        assert_eq!(view.private_key, "the-private-key");
227        assert_eq!(view.public_key, "");
228        assert_eq!(view.fingerprint, "");
229    }
230
231    #[test]
232    fn test_decrypt_derives_missing_public_key_and_fingerprint() {
233        // A cipher with only a private key decrypts to a complete view, deriving the public key
234        // and fingerprint.
235        let key = SymmetricCryptoKey::try_from("hvBMMb1t79YssFZkpetYsM3deyVuQv4r88Uj9gvYe0+G8EwxvW3v1iywVmSl61iwzd17JW5C/ivzxSP2C9h7Tw==".to_string()).unwrap();
236        let key_store = create_test_crypto_with_user_key(key);
237        let key = SymmetricKeySlotId::User;
238        let mut ctx = key_store.context();
239
240        // A real key whose public key and fingerprint are derivable from the private key.
241        let generated = bitwarden_ssh::generator::generate_sshkey(
242            bitwarden_ssh::generator::KeyAlgorithm::Ed25519,
243        )
244        .unwrap();
245
246        let ssh_key = SshKey {
247            private_key: generated
248                .private_key
249                .clone()
250                .encrypt(&mut ctx, key)
251                .unwrap(),
252            public_key: None,
253            fingerprint: None,
254        };
255
256        let view = ssh_key.decrypt(&mut ctx, key).unwrap();
257        assert_eq!(view.private_key, generated.private_key);
258        assert_eq!(view.public_key, generated.public_key);
259        assert_eq!(view.fingerprint, generated.fingerprint);
260        assert!(!view.public_key.is_empty());
261        assert!(!view.fingerprint.is_empty());
262
263        // The list subtitle derives the same fingerprint.
264        assert_eq!(
265            ssh_key.decrypt_subtitle(&mut ctx, key).unwrap(),
266            generated.fingerprint
267        );
268    }
269
270    #[test]
271    fn test_encrypt_composite_derives_missing_fields() {
272        // Write path: a view lacking the public key/fingerprint must persist derived values.
273        let key = SymmetricCryptoKey::try_from("hvBMMb1t79YssFZkpetYsM3deyVuQv4r88Uj9gvYe0+G8EwxvW3v1iywVmSl61iwzd17JW5C/ivzxSP2C9h7Tw==".to_string()).unwrap();
274        let key_store = create_test_crypto_with_user_key(key);
275        let key = SymmetricKeySlotId::User;
276        let mut ctx = key_store.context();
277
278        let generated = bitwarden_ssh::generator::generate_sshkey(
279            bitwarden_ssh::generator::KeyAlgorithm::Ed25519,
280        )
281        .unwrap();
282
283        let view = SshKeyView {
284            private_key: generated.private_key.clone(),
285            public_key: String::new(),
286            fingerprint: String::new(),
287        };
288
289        let encrypted = view.encrypt_composite(&mut ctx, key).unwrap();
290        assert!(encrypted.public_key.is_some());
291        assert!(encrypted.fingerprint.is_some());
292
293        let decrypted = encrypted.decrypt(&mut ctx, key).unwrap();
294        assert_eq!(decrypted.public_key, generated.public_key);
295        assert_eq!(decrypted.fingerprint, generated.fingerprint);
296    }
297
298    #[test]
299    fn test_get_copyable_fields_sshkey() {
300        let ssh_key = SshKey {
301            private_key: "2.tMIugb6zQOL+EuOizna1wQ==|W5dDLoNJtajN68yeOjrr6w==|qS4hwJB0B0gNLI0o+jxn+sKMBmvtVgJCRYNEXBZoGeE=".parse().unwrap(),
302            public_key: Some("2.tMIugb6zQOL+EuOizna1wQ==|W5dDLoNJtajN68yeOjrr6w==|qS4hwJB0B0gNLI0o+jxn+sKMBmvtVgJCRYNEXBZoGeE=".parse().unwrap()),
303            fingerprint: Some("2.tMIugb6zQOL+EuOizna1wQ==|W5dDLoNJtajN68yeOjrr6w==|qS4hwJB0B0gNLI0o+jxn+sKMBmvtVgJCRYNEXBZoGeE=".parse().unwrap()),
304        };
305
306        let copyable_fields = ssh_key.get_copyable_fields(None);
307        assert_eq!(copyable_fields, vec![CopyableCipherFields::SshKey]);
308    }
309}