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 pub private_key: EncString,
24 pub public_key: Option<EncString>,
26 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 pub private_key: String,
38 pub public_key: String,
40 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
54fn 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 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 _ => {
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 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 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 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 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 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 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 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}