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 pub private_key: EncString,
23 pub public_key: Option<EncString>,
25 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 pub private_key: String,
37 pub public_key: String,
39 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
53fn 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 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 _ => {
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 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 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 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 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 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 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 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}