From 38ecf133f86940f9c8f6a9627012938e7569b44d Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 21 Oct 2024 18:05:18 +0200 Subject: [PATCH] [PM-11924] Add ssh-key item type (#1037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🎟ī¸ Tracking https://bitwarden.atlassian.net/browse/PM-11924 Server PR: https://bitwarden.atlassian.net/browse/PM-10394 ## 📔 Objective Add a new item type for ssh-keys. ## ⏰ Reminders before review - Contributor guidelines followed - All formatters and local linters executed and passed - Written new unit and / or integration tests where applicable - Protected functional changes with optionality (feature flags) - Used internationalization (i18n) for all UI strings - CI builds passed - Communicated to DevOps any deployment requirements - Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team ## đŸĻŽ Reviewer guidelines - 👍 (`:+1:`) or similar for great changes - 📝 (`:memo:`) or ℹī¸ (`:information_source:`) for notes or general info - ❓ (`:question:`) for questions - 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion - 🎨 (`:art:`) for suggestions / improvements - ❌ (`:x:`) or ⚠ī¸ (`:warning:`) for more significant problems or concerns needing attention - 🌱 (`:seedling:`) or â™ģī¸ (`:recycle:`) for future improvements or indications of technical debt - ⛏ (`:pick:`) for minor or nitpick changes --------- Co-authored-by: Oscar Hinton --- .../resources/json_export.json | 20 ++++ crates/bitwarden-exporters/src/json.rs | 113 +++++++++++++++++- crates/bitwarden-exporters/src/lib.rs | 11 ++ crates/bitwarden-exporters/src/models.rs | 9 ++ .../bitwarden-vault/src/cipher/attachment.rs | 3 + crates/bitwarden-vault/src/cipher/cipher.rs | 71 ++++++++++- crates/bitwarden-vault/src/cipher/mod.rs | 1 + crates/bitwarden-vault/src/cipher/ssh_key.rs | 49 ++++++++ .../src/mobile/client_ciphers.rs | 2 + 9 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 crates/bitwarden-vault/src/cipher/ssh_key.rs diff --git a/crates/bitwarden-exporters/resources/json_export.json b/crates/bitwarden-exporters/resources/json_export.json index ad538055..27a0921d 100644 --- a/crates/bitwarden-exporters/resources/json_export.json +++ b/crates/bitwarden-exporters/resources/json_export.json @@ -141,6 +141,26 @@ "revisionDate": "2024-01-30T17:54:50.706Z", "creationDate": "2024-01-30T17:54:50.706Z", "deletedDate": null + }, + { + "id": "646594a9-a9cb-4082-9d57-0024c3fbcaa9", + "folderId": null, + "organizationId": null, + "collectionIds": null, + "name": "My ssh key", + "notes": null, + "type": 5, + "sshKey": { + "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBinNE5chMtCHh3BV0H1+CpPlEQBwR5cD+Xb9i8MaHGiwAAAKAy48fwMuPH\n8AAAAAtzc2gtZWQyNTUxOQAAACBinNE5chMtCHh3BV0H1+CpPlEQBwR5cD+Xb9i8MaHGiw\nAAAEAYUCIdfLI14K3XIy9V0FDZLQoZ9gcjOnvFjb4uA335HmKc0TlyEy0IeHcFXQfX4Kk+\nURAHBHlwP5dv2LwxocaLAAAAHHF1ZXh0ZW5ATWFjQm9vay1Qcm8tMTYubG9jYWwB\n-----END OPENSSH PRIVATE KEY-----", + "publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGKc0TlyEy0IeHcFXQfX4Kk+URAHBHlwP5dv2LwxocaL", + "fingerprint": "SHA256:1JjFjvPRkj1Gbf2qRP1dgHiIzEuNAEvp+92x99jw3K0" + }, + "favorite": false, + "reprompt": 0, + "passwordHistory": null, + "revisionDate": "2024-01-30T11:25:25.466Z", + "creationDate": "2024-01-30T11:25:25.466Z", + "deletedDate": null } ] } \ No newline at end of file diff --git a/crates/bitwarden-exporters/src/json.rs b/crates/bitwarden-exporters/src/json.rs index 3f6c72c1..7ed85d45 100644 --- a/crates/bitwarden-exporters/src/json.rs +++ b/crates/bitwarden-exporters/src/json.rs @@ -2,7 +2,9 @@ use chrono::{DateTime, Utc}; use thiserror::Error; use uuid::Uuid; -use crate::{Card, Cipher, CipherType, Field, Folder, Identity, Login, LoginUri, SecureNote}; +use crate::{ + Card, Cipher, CipherType, Field, Folder, Identity, Login, LoginUri, SecureNote, SshKey, +}; #[derive(Error, Debug)] pub enum JsonError { @@ -69,6 +71,8 @@ struct JsonCipher { card: Option, #[serde(skip_serializing_if = "Option::is_none")] secure_note: Option, + #[serde(skip_serializing_if = "Option::is_none")] + ssh_key: Option, favorite: bool, reprompt: u8, @@ -206,6 +210,24 @@ impl From for JsonIdentity { } } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonSshKey { + private_key: Option, + public_key: Option, + fingerprint: Option, +} + +impl From for JsonSshKey { + fn from(ssh_key: SshKey) -> Self { + JsonSshKey { + private_key: ssh_key.private_key, + public_key: ssh_key.public_key, + fingerprint: ssh_key.fingerprint, + } + } +} + #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct JsonField { @@ -233,13 +255,15 @@ impl From for JsonCipher { CipherType::SecureNote(_) => 2, CipherType::Card(_) => 3, CipherType::Identity(_) => 4, + CipherType::SshKey(_) => 5, }; - let (login, secure_note, card, identity) = match cipher.r#type { - CipherType::Login(l) => (Some((*l).into()), None, None, None), - CipherType::SecureNote(s) => (None, Some((*s).into()), None, None), - CipherType::Card(c) => (None, None, Some((*c).into()), None), - CipherType::Identity(i) => (None, None, None, Some((*i).into())), + let (login, secure_note, card, identity, ssh_key) = match cipher.r#type { + CipherType::Login(l) => (Some((*l).into()), None, None, None, None), + CipherType::SecureNote(s) => (None, Some((*s).into()), None, None, None), + CipherType::Card(c) => (None, None, Some((*c).into()), None, None), + CipherType::Identity(i) => (None, None, None, Some((*i).into()), None), + CipherType::SshKey(ssh) => (None, None, None, None, Some((*ssh).into())), }; JsonCipher { @@ -254,6 +278,7 @@ impl From for JsonCipher { identity, card, secure_note, + ssh_key, favorite: cipher.favorite, reprompt: cipher.reprompt, fields: cipher.fields.into_iter().map(|f| f.into()).collect(), @@ -594,6 +619,60 @@ mod tests { ) } + #[test] + fn test_convert_ssh_key() { + let cipher = Cipher { + id: "23f0f877-42b1-4820-a850-b10700bc41eb".parse().unwrap(), + folder_id: None, + + name: "My ssh key".to_string(), + notes: None, + + r#type: CipherType::SshKey(Box::new(SshKey { + private_key: Some("private".to_string()), + public_key: Some("public".to_string()), + fingerprint: Some("fingerprint".to_string()), + })), + + favorite: false, + reprompt: 0, + + fields: vec![], + + revision_date: "2024-01-30T11:25:25.466Z".parse().unwrap(), + creation_date: "2024-01-30T11:25:25.466Z".parse().unwrap(), + deleted_date: None, + }; + + let json = serde_json::to_string(&JsonCipher::from(cipher)).unwrap(); + + let expected = r#"{ + "passwordHistory": null, + "revisionDate": "2024-01-30T11:25:25.466Z", + "creationDate": "2024-01-30T11:25:25.466Z", + "deletedDate": null, + "id": "23f0f877-42b1-4820-a850-b10700bc41eb", + "organizationId": null, + "folderId": null, + "type": 5, + "reprompt": 0, + "name": "My ssh key", + "notes": null, + "sshKey": { + "privateKey": "private", + "publicKey": "public", + "fingerprint": "fingerprint" + }, + "favorite": false, + "collectionIds": null + }"#; + + assert_eq!( + json.parse::().unwrap(), + expected.parse::().unwrap() + ) + } + #[test] pub fn test_export() { let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -750,6 +829,28 @@ mod tests { creation_date: "2024-01-30T17:54:50.706Z".parse().unwrap(), deleted_date: None, }, + Cipher { + id: "646594a9-a9cb-4082-9d57-0024c3fbcaa9".parse().unwrap(), + folder_id: None, + + name: "My ssh key".to_string(), + notes: None, + + r#type: CipherType::SshKey(Box::new(SshKey { + private_key: Some("-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBinNE5chMtCHh3BV0H1+CpPlEQBwR5cD+Xb9i8MaHGiwAAAKAy48fwMuPH\n8AAAAAtzc2gtZWQyNTUxOQAAACBinNE5chMtCHh3BV0H1+CpPlEQBwR5cD+Xb9i8MaHGiw\nAAAEAYUCIdfLI14K3XIy9V0FDZLQoZ9gcjOnvFjb4uA335HmKc0TlyEy0IeHcFXQfX4Kk+\nURAHBHlwP5dv2LwxocaLAAAAHHF1ZXh0ZW5ATWFjQm9vay1Qcm8tMTYubG9jYWwB\n-----END OPENSSH PRIVATE KEY-----".to_string()), + public_key: Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGKc0TlyEy0IeHcFXQfX4Kk+URAHBHlwP5dv2LwxocaL".to_string()), + fingerprint: Some("SHA256:1JjFjvPRkj1Gbf2qRP1dgHiIzEuNAEvp+92x99jw3K0".to_string()), + })), + + favorite: false, + reprompt: 0, + + fields: vec![], + + revision_date: "2024-01-30T11:25:25.466Z".parse().unwrap(), + creation_date: "2024-01-30T11:25:25.466Z".parse().unwrap(), + deleted_date: None, + } ], ) .unwrap(); diff --git a/crates/bitwarden-exporters/src/lib.rs b/crates/bitwarden-exporters/src/lib.rs index 75b0503e..f6bb8dbf 100644 --- a/crates/bitwarden-exporters/src/lib.rs +++ b/crates/bitwarden-exporters/src/lib.rs @@ -70,6 +70,7 @@ pub enum CipherType { SecureNote(Box), Card(Box), Identity(Box), + SshKey(Box), } impl fmt::Display for CipherType { @@ -79,6 +80,7 @@ impl fmt::Display for CipherType { CipherType::SecureNote(_) => write!(f, "note"), CipherType::Card(_) => write!(f, "card"), CipherType::Identity(_) => write!(f, "identity"), + CipherType::SshKey(_) => write!(f, "ssh_key"), } } } @@ -132,3 +134,12 @@ pub struct Identity { pub passport_number: Option, pub license_number: Option, } + +pub struct SshKey { + /// [OpenSSH private key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key), in PEM encoding. + pub private_key: Option, + /// Ssh public key (ed25519/rsa) according to [RFC4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.6) + pub public_key: Option, + /// SSH fingerprint using SHA256 in the format: `SHA256:BASE64_ENCODED_FINGERPRINT` + pub fingerprint: Option, +} diff --git a/crates/bitwarden-exporters/src/models.rs b/crates/bitwarden-exporters/src/models.rs index 1d56e723..1d2e80d9 100644 --- a/crates/bitwarden-exporters/src/models.rs +++ b/crates/bitwarden-exporters/src/models.rs @@ -74,6 +74,14 @@ impl TryFrom for crate::Cipher { license_number: i.license_number, })) } + CipherType::SshKey => { + let s = require!(value.ssh_key); + crate::CipherType::SshKey(Box::new(crate::SshKey { + private_key: s.private_key, + public_key: s.public_key, + fingerprint: s.fingerprint, + })) + } }; Ok(Self { @@ -172,6 +180,7 @@ mod tests { identity: None, card: None, secure_note: None, + ssh_key: None, favorite: false, reprompt: CipherRepromptType::None, organization_use_totp: true, diff --git a/crates/bitwarden-vault/src/cipher/attachment.rs b/crates/bitwarden-vault/src/cipher/attachment.rs index d1a058c0..bf589879 100644 --- a/crates/bitwarden-vault/src/cipher/attachment.rs +++ b/crates/bitwarden-vault/src/cipher/attachment.rs @@ -208,6 +208,7 @@ mod tests { identity: None, card: None, secure_note: None, + ssh_key: None, favorite: false, reprompt: CipherRepromptType::None, organization_use_totp: false, @@ -258,6 +259,7 @@ mod tests { identity: None, card: None, secure_note: None, + ssh_key: None, favorite: false, reprompt: CipherRepromptType::None, organization_use_totp: false, @@ -312,6 +314,7 @@ mod tests { identity: None, card: None, secure_note: None, + ssh_key: None, favorite: false, reprompt: CipherRepromptType::None, organization_use_totp: false, diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 8ac4bfe7..846d332c 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use super::{ attachment, card, field, identity, local_data::{LocalData, LocalDataView}, - secure_note, + secure_note, ssh_key, }; use crate::{ password_history, Fido2CredentialFullView, Fido2CredentialView, Login, LoginView, @@ -41,6 +41,7 @@ pub enum CipherType { SecureNote = 2, Card = 3, Identity = 4, + SshKey = 5, } #[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, JsonSchema, PartialEq)] @@ -72,6 +73,7 @@ pub struct Cipher { pub identity: Option, pub card: Option, pub secure_note: Option, + pub ssh_key: Option, pub favorite: bool, pub reprompt: CipherRepromptType, @@ -109,6 +111,7 @@ pub struct CipherView { pub identity: Option, pub card: Option, pub secure_note: Option, + pub ssh_key: Option, pub favorite: bool, pub reprompt: CipherRepromptType, @@ -137,6 +140,7 @@ pub enum CipherListViewType { SecureNote, Card, Identity, + SshKey, } #[derive(Serialize, Deserialize, Debug, JsonSchema, PartialEq)] @@ -211,6 +215,7 @@ impl KeyEncryptable for CipherView { identity: self.identity.encrypt_with_key(key)?, card: self.card.encrypt_with_key(key)?, secure_note: self.secure_note.encrypt_with_key(key)?, + ssh_key: self.ssh_key.encrypt_with_key(key)?, favorite: self.favorite, reprompt: self.reprompt, organization_use_totp: self.organization_use_totp, @@ -245,6 +250,7 @@ impl KeyDecryptable for Cipher { identity: self.identity.decrypt_with_key(key).ok().flatten(), card: self.card.decrypt_with_key(key).ok().flatten(), secure_note: self.secure_note.decrypt_with_key(key).ok().flatten(), + ssh_key: self.ssh_key.decrypt_with_key(key).ok().flatten(), favorite: self.favorite, reprompt: self.reprompt, organization_use_totp: self.organization_use_totp, @@ -329,6 +335,18 @@ impl Cipher { .transpose()?, ) } + CipherType::SshKey => { + let Some(ssh_key) = &self.ssh_key else { + return Ok(String::new()); + }; + + ssh_key + .fingerprint + .as_ref() + .map(|c| c.decrypt_with_key(key)) + .transpose()? + .unwrap_or_default() + } }) } } @@ -553,6 +571,7 @@ impl KeyDecryptable for Cipher { CipherType::SecureNote => CipherListViewType::SecureNote, CipherType::Card => CipherListViewType::Card, CipherType::Identity => CipherListViewType::Identity, + CipherType::SshKey => CipherListViewType::SshKey, }, favorite: self.favorite, reprompt: self.reprompt, @@ -614,6 +633,8 @@ impl TryFrom for Cipher { identity: cipher.identity.map(|i| (*i).try_into()).transpose()?, card: cipher.card.map(|c| (*c).try_into()).transpose()?, secure_note: cipher.secure_note.map(|s| (*s).try_into()).transpose()?, + // TODO: add ssh_key when api bindings have been updated + ssh_key: None, favorite: cipher.favorite.unwrap_or(false), reprompt: cipher .reprompt @@ -650,6 +671,7 @@ impl From for CipherType { bitwarden_api_api::models::CipherType::SecureNote => CipherType::SecureNote, bitwarden_api_api::models::CipherType::Card => CipherType::Card, bitwarden_api_api::models::CipherType::Identity => CipherType::Identity, + // TODO: add ssh_key when api bindings have been updated } } } @@ -669,6 +691,7 @@ mod tests { use std::collections::HashMap; use attachment::AttachmentView; + use ssh_key::SshKey; use super::*; use crate::Fido2Credential; @@ -695,6 +718,7 @@ mod tests { identity: None, card: None, secure_note: None, + ssh_key: None, favorite: false, reprompt: CipherRepromptType::None, organization_use_totp: true, @@ -753,6 +777,7 @@ mod tests { identity: None, card: None, secure_note: None, + ssh_key: None, favorite: false, reprompt: CipherRepromptType::None, organization_use_totp: false, @@ -1159,4 +1184,48 @@ mod tests { let subtitle = build_subtitle_identity(first_name, last_name); assert_eq!(subtitle, ""); } + + #[test] + fn test_subtitle_ssh_key() { + let key = "hvBMMb1t79YssFZkpetYsM3deyVuQv4r88Uj9gvYe0+G8EwxvW3v1iywVmSl61iwzd17JW5C/ivzxSP2C9h7Tw==".to_string(); + let key = SymmetricCryptoKey::try_from(key).unwrap(); + let original_subtitle = "SHA256:1JjFjvPRkj1Gbf2qRP1dgHiIzEuNAEvp+92x99jw3K0".to_string(); + let fingerprint_encrypted = original_subtitle.to_owned().encrypt_with_key(&key).unwrap(); + let ssh_key_cipher = Cipher { + id: Some("090c19ea-a61a-4df6-8963-262b97bc6266".parse().unwrap()), + organization_id: None, + folder_id: None, + collection_ids: vec![], + r#type: CipherType::SshKey, + key: None, + name: "My test ssh key" + .to_string() + .encrypt_with_key(&key) + .unwrap(), + notes: None, + login: None, + identity: None, + card: None, + secure_note: None, + ssh_key: Some(SshKey { + private_key: None, + public_key: None, + fingerprint: Some(fingerprint_encrypted), + }), + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: false, + edit: true, + view_password: true, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2024-01-01T00:00:00.000Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-01-01T00:00:00.000Z".parse().unwrap(), + }; + let subtitle = ssh_key_cipher.get_decrypted_subtitle(&key).unwrap(); + assert_eq!(subtitle, original_subtitle); + } } diff --git a/crates/bitwarden-vault/src/cipher/mod.rs b/crates/bitwarden-vault/src/cipher/mod.rs index 67513d52..a4a6088d 100644 --- a/crates/bitwarden-vault/src/cipher/mod.rs +++ b/crates/bitwarden-vault/src/cipher/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod linked_id; pub(crate) mod local_data; pub(crate) mod login; pub(crate) mod secure_note; +pub(crate) mod ssh_key; pub use attachment::{ Attachment, AttachmentEncryptResult, AttachmentFile, AttachmentFileView, AttachmentView, diff --git a/crates/bitwarden-vault/src/cipher/ssh_key.rs b/crates/bitwarden-vault/src/cipher/ssh_key.rs new file mode 100644 index 00000000..6d4977c8 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/ssh_key.rs @@ -0,0 +1,49 @@ +use bitwarden_crypto::{ + CryptoError, EncString, KeyDecryptable, KeyEncryptable, SymmetricCryptoKey, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct SshKey { + /// SSH private key (ed25519/rsa) in unencrypted openssh private key format [OpenSSH private key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key) + pub private_key: Option, + /// SSH public key (ed25519/rsa) according to [RFC4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.6) + pub public_key: Option, + /// SSH fingerprint using SHA256 in the format: `SHA256:BASE64_ENCODED_FINGERPRINT` + pub fingerprint: Option, +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct SshKeyView { + /// SSH private key (ed25519/rsa) in unencrypted openssh private key format [OpenSSH private key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key) + pub private_key: Option, + /// SSH public key (ed25519/rsa) according to [RFC4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.6) + pub public_key: Option, + /// SSH fingerprint using SHA256 in the format: `SHA256:BASE64_ENCODED_FINGERPRINT` + pub fingerprint: Option, +} + +impl KeyEncryptable for SshKeyView { + fn encrypt_with_key(self, key: &SymmetricCryptoKey) -> Result { + Ok(SshKey { + private_key: self.private_key.encrypt_with_key(key)?, + public_key: self.public_key.encrypt_with_key(key)?, + fingerprint: self.fingerprint.encrypt_with_key(key)?, + }) + } +} + +impl KeyDecryptable for SshKey { + fn decrypt_with_key(&self, key: &SymmetricCryptoKey) -> Result { + Ok(SshKeyView { + private_key: self.private_key.decrypt_with_key(key).ok().flatten(), + public_key: self.public_key.decrypt_with_key(key).ok().flatten(), + fingerprint: self.fingerprint.decrypt_with_key(key).ok().flatten(), + }) + } +} diff --git a/crates/bitwarden-vault/src/mobile/client_ciphers.rs b/crates/bitwarden-vault/src/mobile/client_ciphers.rs index 345f04df..063fb4bc 100644 --- a/crates/bitwarden-vault/src/mobile/client_ciphers.rs +++ b/crates/bitwarden-vault/src/mobile/client_ciphers.rs @@ -118,6 +118,7 @@ mod tests { identity: None, card: None, secure_note: None, + ssh_key: None, favorite: false, reprompt: CipherRepromptType::None, organization_use_totp: true, @@ -159,6 +160,7 @@ mod tests { identity: None, card: None, secure_note: None, + ssh_key: None, favorite: false, reprompt: CipherRepromptType::None, organization_use_totp: true,