diff --git a/Cargo.lock b/Cargo.lock index 7bb70fdf5..a10836bd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -510,15 +510,23 @@ checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" name = "bw" version = "0.0.2" dependencies = [ + "bat", "bitwarden", "bitwarden-cli", + "chrono", "clap", "color-eyre", + "comfy-table", "env_logger", "inquire", "log", + "serde", + "serde_json", + "serde_yaml", + "supports-color", "tempfile", "tokio", + "uuid", ] [[package]] diff --git a/crates/bitwarden/src/admin_console/auth_requests/approve.rs b/crates/bitwarden/src/admin_console/auth_requests/approve.rs new file mode 100644 index 000000000..4776bb22a --- /dev/null +++ b/crates/bitwarden/src/admin_console/auth_requests/approve.rs @@ -0,0 +1,118 @@ +use bitwarden_api_api::models::{ + AdminAuthRequestUpdateRequestModel, OrganizationUserResetPasswordDetailsResponseModel, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + client::Client, + crypto::{encrypt_rsa, private_key_from_bytes, public_key_from_b64, Decryptable, EncString}, + error::{Error, Result}, +}; + +use super::{list_pending_requests, PendingAuthRequestResponse}; + +// TODO: what identifier should this take? e.g. org_user_id, request_id, etc +// using org_user_id for now +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthApproveRequest { + pub organization_user_id: Uuid, + pub organization_id: Uuid, +} + +pub(crate) async fn approve_auth_request( + client: &mut Client, + input: &AuthApproveRequest, +) -> Result<()> { + let device_request = + get_pending_request(input.organization_id, input.organization_user_id, client).await; + + // Get user reset password details + let reset_password_details = + bitwarden_api_api::apis::organization_users_api::organizations_org_id_users_id_reset_password_details_get( +&client.get_api_configurations().await.api, + &input.organization_id.to_string(), + &device_request.id.to_string(), + ) + .await?; + + let encrypted_user_key = get_encrypted_user_key( + &client, + input.organization_id, + &device_request, + reset_password_details, + )?; + + bitwarden_api_api::apis::organization_auth_requests_api::organizations_org_id_auth_requests_request_id_post( +&client.get_api_configurations().await.api, + input.organization_id, + device_request.id, + Some(AdminAuthRequestUpdateRequestModel { + encrypted_user_key: Some(encrypted_user_key.to_string()), + request_approved: true + }) + ) + .await?; + + Ok(()) +} + +async fn get_pending_request( + organization_id: Uuid, + organization_user_id: Uuid, + client: &mut Client, +) -> PendingAuthRequestResponse { + // hack: get all approval details and then find the one we want + // when we settle on an identifier then we should just give ourselves a better server API + // or do we require the caller to pass all this info in? + let all_device_requests = list_pending_requests( + client, + &super::PendingAuthRequestsRequest { + organization_id: organization_id, + }, + ) + .await; + + all_device_requests + .unwrap() + .data + .into_iter() + .find(|r| r.organization_user_id == organization_user_id) + .unwrap() // TODO: error handling +} + +fn get_encrypted_user_key( + client: &Client, + organization_id: Uuid, + input: &PendingAuthRequestResponse, + reset_password_details: OrganizationUserResetPasswordDetailsResponseModel, +) -> Result { + // Decrypt organization's encrypted private key with org key + let enc = client.get_encryption_settings()?; + + let org_private_key = { + let dec = reset_password_details + .encrypted_private_key + .ok_or(Error::MissingFields)? + .parse::()? + .decrypt(enc, &Some(organization_id))? + .into_bytes(); + + private_key_from_bytes(&dec)? + }; + + // Decrypt user key with org private key + let user_key = &reset_password_details + .reset_password_key + .ok_or(Error::MissingFields)? + .parse::()?; + let dec_user_key = user_key.decrypt_with_rsa_key(&org_private_key)?; + + // re-encrypt user key with device public key + let device_public_key = public_key_from_b64(&input.public_key_b64)?; + let re_encrypted_user_key = encrypt_rsa(dec_user_key, &device_public_key)?; + + EncString::from_buffer(&re_encrypted_user_key) +} diff --git a/crates/bitwarden/src/admin_console/auth_requests/list.rs b/crates/bitwarden/src/admin_console/auth_requests/list.rs new file mode 100644 index 000000000..ba152b885 --- /dev/null +++ b/crates/bitwarden/src/admin_console/auth_requests/list.rs @@ -0,0 +1,92 @@ +use bitwarden_api_api::models::{ + PendingOrganizationAuthRequestResponseModel, + PendingOrganizationAuthRequestResponseModelListResponseModel, +}; +use chrono::{DateTime, Utc}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + client::Client, + error::{Error, Result}, +}; + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PendingAuthRequestsRequest { + /// Organization to retrieve pending auth requests for + pub organization_id: Uuid, +} + +pub(crate) async fn list_pending_requests( + client: &mut Client, + input: &PendingAuthRequestsRequest, +) -> Result { + let config = client.get_api_configurations().await; + let res = bitwarden_api_api::apis::organization_auth_requests_api::organizations_org_id_auth_requests_get( + &config.api, + input.organization_id, + ) + .await?; + + PendingAuthRequestsResponse::process_response(res) +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PendingAuthRequestsResponse { + pub data: Vec, +} + +impl PendingAuthRequestsResponse { + pub(crate) fn process_response( + response: PendingOrganizationAuthRequestResponseModelListResponseModel, + ) -> Result { + Ok(PendingAuthRequestsResponse { + data: response + .data + .unwrap_or_default() + .into_iter() + .map(|r| PendingAuthRequestResponse::process_response(r)) + .collect::>()?, + }) + } +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PendingAuthRequestResponse { + pub id: Uuid, + pub user_id: Uuid, + pub organization_user_id: Uuid, + pub email: String, + pub public_key_b64: String, + pub request_device_identifier: String, + pub request_device_type: String, + pub request_ip_address: String, + pub creation_date: DateTime, +} + +impl PendingAuthRequestResponse { + pub(crate) fn process_response( + response: PendingOrganizationAuthRequestResponseModel, + ) -> Result { + Ok(PendingAuthRequestResponse { + id: response.id.ok_or(Error::MissingFields)?, + user_id: response.user_id.ok_or(Error::MissingFields)?, + organization_user_id: response.organization_user_id.ok_or(Error::MissingFields)?, + email: response.email.ok_or(Error::MissingFields)?, + public_key_b64: response.public_key.ok_or(Error::MissingFields)?, + request_device_identifier: response + .request_device_identifier + .ok_or(Error::MissingFields)?, + request_device_type: response.request_device_type.ok_or(Error::MissingFields)?, + request_ip_address: response.request_ip_address.ok_or(Error::MissingFields)?, + creation_date: response + .creation_date + .ok_or(Error::MissingFields)? + .parse()?, + }) + } +} diff --git a/crates/bitwarden/src/admin_console/auth_requests/mod.rs b/crates/bitwarden/src/admin_console/auth_requests/mod.rs new file mode 100644 index 000000000..879559e31 --- /dev/null +++ b/crates/bitwarden/src/admin_console/auth_requests/mod.rs @@ -0,0 +1,10 @@ +mod approve; +mod list; + +pub(crate) use list::list_pending_requests; +pub use list::{ + PendingAuthRequestResponse, PendingAuthRequestsRequest, PendingAuthRequestsResponse, +}; + +pub(crate) use approve::approve_auth_request; +pub use approve::AuthApproveRequest; diff --git a/crates/bitwarden/src/admin_console/client_auth_requests.rs b/crates/bitwarden/src/admin_console/client_auth_requests.rs new file mode 100644 index 000000000..37f032e79 --- /dev/null +++ b/crates/bitwarden/src/admin_console/client_auth_requests.rs @@ -0,0 +1,31 @@ +use crate::{ + admin_console::auth_requests::{approve_auth_request, AuthApproveRequest}, + admin_console::auth_requests::{ + list_pending_requests, PendingAuthRequestsRequest, PendingAuthRequestsResponse, + }, + error::Result, + Client, +}; + +pub struct ClientAuthRequests<'a> { + pub(crate) client: &'a mut crate::Client, +} + +impl<'a> ClientAuthRequests<'a> { + pub async fn list( + &mut self, + input: &PendingAuthRequestsRequest, + ) -> Result { + list_pending_requests(self.client, input).await + } + + pub async fn approve(&mut self, input: &AuthApproveRequest) -> Result<()> { + approve_auth_request(self.client, input).await + } +} + +impl<'a> Client { + pub fn client_auth_requests(&'a mut self) -> ClientAuthRequests<'a> { + ClientAuthRequests { client: self } + } +} diff --git a/crates/bitwarden/src/admin_console/mod.rs b/crates/bitwarden/src/admin_console/mod.rs new file mode 100644 index 000000000..67fb832bd --- /dev/null +++ b/crates/bitwarden/src/admin_console/mod.rs @@ -0,0 +1,3 @@ +pub mod auth_requests; + +mod client_auth_requests; diff --git a/crates/bitwarden/src/client/encryption_settings.rs b/crates/bitwarden/src/client/encryption_settings.rs index 9c79a1781..58aece281 100644 --- a/crates/bitwarden/src/client/encryption_settings.rs +++ b/crates/bitwarden/src/client/encryption_settings.rs @@ -81,15 +81,7 @@ impl EncryptionSettings { // Decrypt the org keys with the private key for (org_id, org_enc_key) in org_enc_keys { - let data = match org_enc_key { - EncString::Rsa2048_OaepSha1_B64 { data } => data, - _ => return Err(CryptoError::InvalidKey.into()), - }; - - let dec = private_key - .decrypt(Oaep::new::(), &data) - .map_err(|_| CryptoError::KeyDecrypt)?; - + let dec = org_enc_key.decrypt_with_rsa_key(private_key)?; let org_key = SymmetricCryptoKey::try_from(dec.as_slice())?; self.org_keys.insert(org_id, org_key); diff --git a/crates/bitwarden/src/crypto/enc_string.rs b/crates/bitwarden/src/crypto/enc_string.rs index b701aaf8f..b55304efc 100644 --- a/crates/bitwarden/src/crypto/enc_string.rs +++ b/crates/bitwarden/src/crypto/enc_string.rs @@ -1,12 +1,13 @@ use std::{fmt::Display, str::FromStr}; use base64::Engine; +use rsa::RsaPrivateKey; use serde::{de::Visitor, Deserialize}; use uuid::Uuid; use crate::{ client::encryption_settings::EncryptionSettings, - crypto::{decrypt_aes256_hmac, Decryptable, Encryptable, SymmetricCryptoKey}, + crypto::{decrypt_aes256_hmac, rsa::decrypt_rsa, Decryptable, Encryptable, SymmetricCryptoKey}, error::{CryptoError, EncStringParseError, Error, Result}, util::BASE64_ENGINE, }; @@ -315,6 +316,13 @@ impl EncString { _ => Err(CryptoError::InvalidKey.into()), } } + + pub fn decrypt_with_rsa_key(&self, key: &RsaPrivateKey) -> Result> { + match self { + EncString::Rsa2048_OaepSha1_B64 { data } => decrypt_rsa(data.clone(), key), + _ => Err(CryptoError::InvalidKey.into()), + } + } } fn invalid_len_error(expected: usize) -> impl Fn(Vec) -> EncStringParseError { diff --git a/crates/bitwarden/src/crypto/mod.rs b/crates/bitwarden/src/crypto/mod.rs index b35157981..0e6fb1f16 100644 --- a/crates/bitwarden/src/crypto/mod.rs +++ b/crates/bitwarden/src/crypto/mod.rs @@ -45,11 +45,16 @@ pub(crate) use master_key::{HashPurpose, MasterKey}; mod user_key; #[cfg(feature = "internal")] pub(crate) use user_key::UserKey; -#[cfg(feature = "internal")] +// #[cfg(feature = "internal")] mod rsa; #[cfg(feature = "internal")] +pub use self::rsa::encrypt_rsa; +#[cfg(feature = "internal")] +pub use self::rsa::private_key_from_bytes; +#[cfg(feature = "internal")] +pub use self::rsa::public_key_from_b64; +#[cfg(feature = "internal")] pub use self::rsa::RsaKeyPair; - #[cfg(feature = "internal")] mod fingerprint; #[cfg(feature = "internal")] diff --git a/crates/bitwarden/src/crypto/rsa.rs b/crates/bitwarden/src/crypto/rsa.rs index 0d2d135b9..95c158ad1 100644 --- a/crates/bitwarden/src/crypto/rsa.rs +++ b/crates/bitwarden/src/crypto/rsa.rs @@ -1,12 +1,15 @@ use base64::Engine; use rsa::{ - pkcs8::{EncodePrivateKey, EncodePublicKey}, - RsaPrivateKey, RsaPublicKey, + pkcs8::{ + der::Decode, DecodePrivateKey, EncodePrivateKey, EncodePublicKey, SubjectPublicKeyInfo, + }, + Oaep, RsaPrivateKey, RsaPublicKey, }; +use sha1::Sha1; use crate::{ crypto::{encrypt_aes256_hmac, EncString, SymmetricCryptoKey}, - error::{Error, Result}, + error::{CryptoError, Error, Result}, util::BASE64_ENGINE, }; @@ -40,3 +43,94 @@ pub(super) fn make_key_pair(key: &SymmetricCryptoKey) -> Result { private: protected, }) } + +pub(super) fn decrypt_rsa(data: Vec, key: &RsaPrivateKey) -> Result> { + key.decrypt(Oaep::new::(), &data) + .map_err(|_| CryptoError::InvalidKey.into()) // need better error +} + +pub fn encrypt_rsa(data: Vec, key: &RsaPublicKey) -> Result> { + let mut rng = rand::thread_rng(); + key.encrypt(&mut rng, Oaep::new::(), &data) + .map_err(|_| CryptoError::InvalidKey.into()) // need better error +} + +pub fn public_key_from_b64(b64: &str) -> Result { + let public_key_bytes = BASE64_ENGINE.decode(b64)?; + let public_key_info = SubjectPublicKeyInfo::from_der(&public_key_bytes).unwrap(); // TODO: error handling + RsaPublicKey::try_from(public_key_info).map_err(|_| Error::Crypto(CryptoError::InvalidKey)) +} + +pub fn private_key_from_bytes(bytes: &Vec) -> Result { + rsa::RsaPrivateKey::from_pkcs8_der(bytes).map_err(|_| Error::Crypto(CryptoError::InvalidKey)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::BASE64_ENGINE; + use base64::Engine; + use rsa::pkcs8::{der::Decode, DecodePrivateKey, SubjectPublicKeyInfo}; + + const PRIVATE_KEY_B64: &str = concat!( + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXRVrCX+2hfOQS8Hz", + "YUS2oc/jGVTZpv+/Ryuoh9d8ihYX9dd0cYh2tl6KWdFc88lPUH11Oxqy20Rk2e5r/RF6T9yM0Me3NPnaKt+hlhLtfoc0h86L", + "nhD56A9FDUfuI0dVnPcrwNv0YJIo94LwxtbqBULNvXl6wJ7WAbODrCQy5ZgMVg+iH+gGpwiqsZqHt+KuoHWcN53MSPDfaF4/", + "YMB99U3TziJMOOJask1TEEnakMPln11PczNDazT17DXIxYrbPfutPdh6sLs6AQOajdZijfEvepgnOe7cQ7aeatiOJFrjTApK", + "PGxOVRzEMX4XS4xbyhH0QxQeB6l16l8C0uxIBAgMBAAECggEASaWfeVDA3cVzOPFSpvJm20OTE+R6uGOU+7vh36TX/POq92q", + "Buwbd0h0oMD32FxsXywd2IxtBDUSiFM9699qufTVuM0Q3tZw6lHDTOVG08+tPdr8qSbMtw7PGFxN79fHLBxejjO4IrM9lapj", + "WpxEF+11x7r+wM+0xRZQ8sNFYG46aPfIaty4BGbL0I2DQ2y8I57iBCAy69eht59NLMm27fRWGJIWCuBIjlpfzET1j2HLXUIh", + "5bTBNzqaN039WH49HczGE3mQKVEJZc/efk3HaVd0a1Sjzyn0QY+N1jtZN3jTRbuDWA1AknkX1LX/0tUhuS3/7C3ejHxjw4Dk", + "1ZLo5/QKBgQDIWvqFn0+IKRSu6Ua2hDsufIHHUNLelbfLUMmFthxabcUn4zlvIscJO00Tq/ezopSRRvbGiqnxjv/mYxucvOU", + "BeZtlus0Q9RTACBtw9TGoNTmQbEunJ2FOSlqbQxkBBAjgGEppRPt30iGj/VjAhCATq2MYOa/X4dVR51BqQAFIEwKBgQDBSIf", + "TFKC/hDk6FKZlgwvupWYJyU9RkyfstPErZFmzoKhPkQ3YORo2oeAYmVUbS9I2iIYpYpYQJHX8jMuCbCz4ONxTCuSIXYQYUcU", + "q4PglCKp31xBAE6TN8SvhfME9/MvuDssnQinAHuF0GDAhF646T3LLS1not6Vszv7brwSoGwKBgQC88v/8cGfi80ssQZeMnVv", + "q1UTXIeQcQnoY5lGHJl3K8mbS3TnXE6c9j417Fdz+rj8KWzBzwWXQB5pSPflWcdZO886Xu/mVGmy9RWgLuVFhXwCwsVEPjNX", + "5ramRb0/vY0yzenUCninBsIxFSbIfrPtLUYCc4hpxr+sr2Mg/y6jpvQKBgBezMRRs3xkcuXepuI2R+BCXL1/b02IJTUf1F+1", + "eLLGd7YV0H+J3fgNc7gGWK51hOrF9JBZHBGeOUPlaukmPwiPdtQZpu4QNE3l37VlIpKTF30E6mb+BqR+nht3rUjarnMXgAoE", + "Z18y6/KIjpSMpqC92Nnk/EBM9EYe6Cf4eA9ApAoGAeqEUg46UTlJySkBKURGpIs3v1kkf5I0X8DnOhwb+HPxNaiEdmO7ckm8", + "+tPVgppLcG0+tMdLjigFQiDUQk2y3WjyxP5ZvXu7U96jaJRI8PFMoE06WeVYcdIzrID2HvqH+w0UQJFrLJ/0Mn4stFAEzXKZ", + "BokBGnjFnTnKcs7nv/O8="); + + const PUBLIC_KEY_B64: &str = concat!( + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl0Vawl/toXzkEvB82FEtqHP", + "4xlU2ab/v0crqIfXfIoWF/XXdHGIdrZeilnRXPPJT1B9dTsasttEZNnua/0Rek/cjNDHtzT52irfoZYS7X6HNIfOi54Q+egP", + "RQ1H7iNHVZz3K8Db9GCSKPeC8MbW6gVCzb15esCe1gGzg6wkMuWYDFYPoh/oBqcIqrGah7firqB1nDedzEjw32heP2DAffVN", + "084iTDjiWrJNUxBJ2pDD5Z9dT3MzQ2s09ew1yMWK2z37rT3YerC7OgEDmo3WYo3xL3qYJznu3EO2nmrYjiRa40wKSjxsTlUc", + "xDF+F0uMW8oR9EMUHgepdepfAtLsSAQIDAQAB"); + + const DATA_B64: &str = concat!( + "A1/p8BQzN9UrbdYxUY2Va5+kPLyfZXF9JsZrjeEXcaclsnHurdxVAJcnbEqYMP3UXV", + "4YAS/mpf+Rxe6/X0WS1boQdA0MAHSgx95hIlAraZYpiMLLiJRKeo2u8YivCdTM9V5vuAEJwf9Tof/qFsFci3sApdbATkorCT", + "zFOIEPF2S1zgperEP23M01mr4dWVdYN18B32YF67xdJHMbFhp5dkQwv9CmscoWq7OE5HIfOb+JAh7BEZb+CmKhM3yWJvoR/D", + "/5jcercUtK2o+XrzNrL4UQ7yLZcFz6Bfwb/j6ICYvqd/YJwXNE6dwlL57OfwJyCdw2rRYf0/qI00t9u8Iitw=="); + + #[test] + fn test_decrypt_rsa() { + let private_key_bytes = BASE64_ENGINE.decode(PRIVATE_KEY_B64).unwrap(); + let private_key = rsa::RsaPrivateKey::from_pkcs8_der(&private_key_bytes).unwrap(); + let data_bytes = BASE64_ENGINE.decode(DATA_B64).unwrap(); + + let result = decrypt_rsa(data_bytes, &private_key).unwrap(); + let result_string = String::from_utf8(result).unwrap(); + + assert_eq!(result_string, "EncryptMe!"); + } + + #[test] + fn test_encrypt_rsa() { + let public_key_bytes = BASE64_ENGINE.decode(PUBLIC_KEY_B64).unwrap(); + let info = SubjectPublicKeyInfo::from_der(&public_key_bytes).unwrap(); + let public_key = RsaPublicKey::try_from(info).unwrap(); + + let private_key_bytes = BASE64_ENGINE.decode(PRIVATE_KEY_B64).unwrap(); + let private_key = rsa::RsaPrivateKey::from_pkcs8_der(&private_key_bytes).unwrap(); + + let encrypted = encrypt_rsa("EncryptMe!".as_bytes().to_vec(), &public_key).unwrap(); + let decrypted = decrypt_rsa(encrypted, &private_key).unwrap(); + + let result_string = String::from_utf8(decrypted).unwrap(); + + assert_eq!(result_string, "EncryptMe!"); + } +} diff --git a/crates/bitwarden/src/lib.rs b/crates/bitwarden/src/lib.rs index a61857992..1caf754af 100644 --- a/crates/bitwarden/src/lib.rs +++ b/crates/bitwarden/src/lib.rs @@ -51,6 +51,7 @@ #[cfg(feature = "mobile")] uniffi::setup_scaffolding!(); +pub mod admin_console; pub mod auth; pub mod client; pub mod crypto; diff --git a/crates/bw/Cargo.toml b/crates/bw/Cargo.toml index 0793470b3..2c012f467 100644 --- a/crates/bw/Cargo.toml +++ b/crates/bw/Cargo.toml @@ -21,6 +21,19 @@ log = "0.4.18" env_logger = "0.10.0" color-eyre = "0.6" inquire = "0.6.2" +uuid = { version = "^1.3.3", features = ["serde"] } +serde = "^1.0.163" +serde_json = "^1.0.96" +serde_yaml = "0.9" +bat = { version = "0.24.0", features = [ + "regex-onig", +], default-features = false } +chrono = { version = "0.4.26", features = [ + "clock", + "std", +], default-features = false } +comfy-table = "^7.0.1" +supports-color = "2.0.0" bitwarden = { path = "../bitwarden", version = "0.3.1", features = [ "internal", diff --git a/crates/bw/src/auth/login.rs b/crates/bw/src/auth/login.rs index 1c169817f..dd1e7af3a 100644 --- a/crates/bw/src/auth/login.rs +++ b/crates/bw/src/auth/login.rs @@ -84,7 +84,7 @@ pub(crate) async fn api_key_login( mut client: Client, client_id: Option, client_secret: Option, -) -> Result<()> { +) -> Result { let client_id = text_prompt_when_none("Client ID", client_id)?; let client_secret = text_prompt_when_none("Client Secret", client_secret)?; @@ -100,5 +100,5 @@ pub(crate) async fn api_key_login( debug!("{:?}", result); - Ok(()) + Ok(client) } diff --git a/crates/bw/src/main.rs b/crates/bw/src/main.rs index 73e64a4aa..4fb5cb2fb 100644 --- a/crates/bw/src/main.rs +++ b/crates/bw/src/main.rs @@ -1,11 +1,16 @@ use bitwarden::{ - auth::RegisterRequest, client::client_settings::ClientSettings, tool::PasswordGeneratorRequest, + admin_console::auth_requests::{AuthApproveRequest, PendingAuthRequestsRequest}, + auth::RegisterRequest, + client::client_settings::ClientSettings, + tool::PasswordGeneratorRequest, + Client, }; use bitwarden_cli::{install_color_eyre, text_prompt_when_none, Color}; use clap::{command, Args, CommandFactory, Parser, Subcommand}; use color_eyre::eyre::Result; use inquire::Password; -use render::Output; +use render::{serialize_response, Output}; +use uuid::Uuid; mod auth; mod render; @@ -55,6 +60,12 @@ enum Commands { #[command(subcommand)] command: GeneratorCommands, }, + + #[command(long_about = "Manage your organization")] + AdminConsole { + #[command(subcommand)] + command: AdminConsoleCommands, + }, } #[derive(Args, Clone)] @@ -90,6 +101,17 @@ enum GeneratorCommands { Passphrase {}, } +#[derive(Subcommand, Clone)] +enum AdminConsoleCommands { + ListDevices { + organization_id: Uuid, + }, + ApproveDevice { + organization_id: Uuid, + organization_user_id: Uuid, + }, +} + #[derive(Args, Clone)] struct PasswordGeneratorArgs { #[arg(short = 'l', long, action, help = "Include lowercase characters (a-z)")] @@ -120,6 +142,19 @@ async fn main() -> Result<()> { process_commands().await } +async fn hack_login() -> Client { + // hack login + let server = "https://vault.qa.bitwarden.pw"; + let settings = ClientSettings { + api_url: format!("{}/api", server), + identity_url: format!("{}/identity", server), + ..Default::default() + }; + let client = bitwarden::Client::new(Some(settings)); + + auth::api_key_login(client, None, None).await.unwrap() +} + async fn process_commands() -> Result<()> { let cli = Cli::parse(); @@ -148,7 +183,9 @@ async fn process_commands() -> Result<()> { LoginCommands::ApiKey { client_id, client_secret, - } => auth::api_key_login(client, client_id, client_secret).await?, + } => { + auth::api_key_login(client, client_id, client_secret).await?; + } } return Ok(()); } @@ -182,7 +219,7 @@ async fn process_commands() -> Result<()> { } // Not login, assuming we have a config - let client = bitwarden::Client::new(None); + let mut client = bitwarden::Client::new(None); // And finally we process all the commands which require authentication match command { @@ -208,6 +245,31 @@ async fn process_commands() -> Result<()> { } GeneratorCommands::Passphrase {} => todo!(), }, + Commands::AdminConsole { command } => match command { + AdminConsoleCommands::ListDevices { organization_id } => { + let mut client = hack_login().await; + let auth_requests = client + .client_auth_requests() + .list(&PendingAuthRequestsRequest { organization_id }) + .await?; + + serialize_response(auth_requests.data, cli.output, false); + } + AdminConsoleCommands::ApproveDevice { + organization_id, + organization_user_id, + } => { + let mut client = hack_login().await; + client + .client_auth_requests() + .approve(&AuthApproveRequest { + organization_id, + organization_user_id, + }) + .await + .unwrap(); // error handling? + } + }, }; Ok(()) diff --git a/crates/bw/src/render.rs b/crates/bw/src/render.rs index da8ed4997..6baa64d42 100644 --- a/crates/bw/src/render.rs +++ b/crates/bw/src/render.rs @@ -1,4 +1,10 @@ +use bitwarden::admin_console::auth_requests::{ + PendingAuthRequestResponse, PendingAuthRequestsResponse, +}; +use chrono::{DateTime, Utc}; use clap::ValueEnum; +use comfy_table::Table; +use serde::Serialize; #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] #[allow(clippy::upper_case_acronyms)] @@ -9,3 +15,113 @@ pub(crate) enum Output { TSV, None, } + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] +pub(crate) enum Color { + No, + Yes, + Auto, +} + +impl Color { + pub(crate) fn is_enabled(self) -> bool { + match self { + Color::No => false, + Color::Yes => true, + Color::Auto => supports_color::on(supports_color::Stream::Stdout).is_some(), + } + } +} + +const ASCII_HEADER_ONLY: &str = " -- "; + +pub(crate) fn serialize_response, const N: usize>( + data: T, + output: Output, + color: bool, +) { + match output { + Output::JSON => { + let mut text = serde_json::to_string_pretty(&data).unwrap(); + // Yaml/table/tsv serializations add a newline at the end, so we do the same here for consistency + text.push('\n'); + pretty_print("json", &text, color); + } + Output::YAML => { + let text = serde_yaml::to_string(&data).unwrap(); + pretty_print("yaml", &text, color); + } + Output::Table => { + let mut table = Table::new(); + table + .load_preset(ASCII_HEADER_ONLY) + .set_header(T::get_headers()) + .add_rows(data.get_values()); + + println!("{table}"); + } + Output::TSV => { + println!("{}", T::get_headers().join("\t")); + + let rows: Vec = data + .get_values() + .into_iter() + .map(|row| row.join("\t")) + .collect(); + println!("{}", rows.join("\n")); + } + Output::None => {} + } +} + +fn pretty_print(language: &str, data: &str, color: bool) { + if color { + bat::PrettyPrinter::new() + .input_from_bytes(data.as_bytes()) + .language(language) + .print() + .unwrap(); + } else { + print!("{}", data); + } +} + +// We're using const generics for the array lengths to make sure the header count and value count match +pub(crate) trait TableSerialize: Sized { + fn get_headers() -> [&'static str; N]; + fn get_values(&self) -> Vec<[String; N]>; +} + +// Generic impl for Vec so we can call `serialize_response` with both individual +// elements and lists of elements, like we do with the JSON and YAML cases +impl, const N: usize> TableSerialize for Vec { + fn get_headers() -> [&'static str; N] { + T::get_headers() + } + fn get_values(&self) -> Vec<[String; N]> { + let mut values = Vec::new(); + for t in self { + values.append(&mut t.get_values()); + } + values + } +} + +fn format_date(date: &DateTime) -> String { + date.format("%Y-%m-%d %H:%M:%S").to_string() +} + +impl TableSerialize<4> for PendingAuthRequestResponse { + fn get_headers() -> [&'static str; 4] { + ["ID", "User ID", "Organization User ID", "Email"] + } + + fn get_values(&self) -> Vec<[String; 4]> { + vec![[ + self.id.to_string(), + self.user_id.to_string(), + self.organization_user_id.to_string(), + self.email.clone(), + ]] + } +}