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