diff --git a/Cargo.lock b/Cargo.lock index def5c850c3..773d3008e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1078,6 +1078,7 @@ dependencies = [ "bitwarden-error", "bitwarden-organization-crypto", "chrono", + "http", "serde", "thiserror", "tokio", diff --git a/crates/bitwarden-organization-invite-link/Cargo.toml b/crates/bitwarden-organization-invite-link/Cargo.toml index 0487850a88..e3d2642b45 100644 --- a/crates/bitwarden-organization-invite-link/Cargo.toml +++ b/crates/bitwarden-organization-invite-link/Cargo.toml @@ -35,6 +35,7 @@ bitwarden-encoding = { workspace = true } bitwarden-error = { workspace = true } bitwarden-organization-crypto = { workspace = true } chrono = { workspace = true } +http = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } tsify = { workspace = true, optional = true } diff --git a/crates/bitwarden-organization-invite-link/src/error.rs b/crates/bitwarden-organization-invite-link/src/error.rs new file mode 100644 index 0000000000..8a6cdd8727 --- /dev/null +++ b/crates/bitwarden-organization-invite-link/src/error.rs @@ -0,0 +1,34 @@ +use bitwarden_core::{ApiError, MissingFieldError}; +use bitwarden_crypto::CryptoError; +use bitwarden_error::bitwarden_error; +use bitwarden_organization_crypto::invite::InviteKeyBundleError; +use thiserror::Error; + +/// Errors returned from invite link client operations. +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum InviteLinkError { + /// A cryptographic invite operation (creating, unsealing, or recovering the invite) failed. + #[error(transparent)] + Invite(#[from] InviteKeyBundleError), + /// A network request to the server failed. + #[error(transparent)] + Api(#[from] ApiError), + /// A low-level cryptographic operation (key wrapping, encapsulation, or public-key parsing) + /// failed. + #[error(transparent)] + Crypto(#[from] CryptoError), + /// A required field was missing from a server response. + #[error(transparent)] + MissingField(#[from] MissingFieldError), + /// A value was present but malformed and could not be parsed. + #[error("Failed to parse `{0}`")] + ParseFailure(&'static str), + /// The account-recovery public key returned by the server does not match the organization + /// public key bound into the invite. + #[error("Account recovery public key does not match the invite's bound organization key")] + RecoveryKeyMismatch, + /// No allowed domains were specified. + #[error("At least one allowed domain is required.")] + NoAllowedDomains, +} diff --git a/crates/bitwarden-organization-invite-link/src/invite_link_admin_client.rs b/crates/bitwarden-organization-invite-link/src/invite_link_admin_client.rs new file mode 100644 index 0000000000..c9fc5db0f4 --- /dev/null +++ b/crates/bitwarden-organization-invite-link/src/invite_link_admin_client.rs @@ -0,0 +1,576 @@ +use std::sync::Arc; + +use bitwarden_api_api::models::{ + CreateOrganizationInviteLinkRequestModel, RefreshOrganizationInviteLinkRequestModel, + UpdateOrganizationInviteLinkRequestModel, +}; +use bitwarden_core::{ + ApiError, FromClient, OrganizationId, + client::ApiConfigurations, + key_management::{KeySlotIds, SymmetricKeySlotId}, + require, +}; +use bitwarden_crypto::{EncString, KeyStore}; +use bitwarden_organization_crypto::invite::{Invite, InviteSecret}; +use http::StatusCode; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::{InviteLinkError, OrganizationInviteLink, OrganizationInviteLinkView}; + +/// Client for organization invite link administrative (organization-key) operations: creating, +/// refreshing, updating, deleting, and inspecting invite links, and recovering the invite secret. +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[derive(FromClient)] +pub struct InviteLinkAdminClient { + pub(crate) key_store: KeyStore, + pub(crate) api_configurations: Arc, +} + +// The `create`/`refresh` methods delegate to the deprecated `create_invite_link`/ +// `refresh_invite_link`, and the `wasm_bindgen`-generated shims for those deprecated exports call +// them; both would otherwise emit deprecation warnings from code we cannot annotate individually. +#[allow(deprecated)] +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl InviteLinkAdminClient { + /// Get an existing invite link. + pub async fn get( + &self, + organization_id: OrganizationId, + ) -> Result, InviteLinkError> { + let response = match self + .api_configurations + .api_client + .organization_invite_links_api() + .get(organization_id.into()) + .await + { + Ok(response) => response, + Err(ApiError::Response(rc)) if rc.status == StatusCode::NOT_FOUND => return Ok(None), + Err(e) => return Err(e.into()), + }; + + let mut ctx = self.key_store.context(); + OrganizationInviteLink::try_from(response) + .and_then(|link| link.to_view(&mut ctx)) + .map(Some) + } + + /// Delete an existing invite link. + pub async fn delete(&self, organization_id: OrganizationId) -> Result<(), InviteLinkError> { + self.api_configurations + .api_client + .organization_invite_links_api() + .delete(organization_id.into()) + .await?; + Ok(()) + } + + /// Creates a new organization invite and posts it to the server, returning the full + /// [`OrganizationInviteLink`] persisted by the server. + /// + /// # Security + /// Only the sealed invite is posted to the server; the invite secret is never sent. Use + /// [`InviteLinkAdminClient::get_invite_secret`] to recover the secret needed to reconstruct the + /// invite link. + #[deprecated(note = "Use `create`, which returns an `OrganizationInviteLinkView`, instead")] + pub async fn create_invite_link( + &self, + organization_id: OrganizationId, + allowed_domains: Vec, + supports_confirmation: bool, + ) -> Result { + let invite = self + .make_invite(organization_id, supports_confirmation) + .await?; + + let response = self + .api_configurations + .api_client + .organization_invite_links_api() + .create( + organization_id.into(), + Some(CreateOrganizationInviteLinkRequestModel { + allowed_domains, + invite: String::from(&invite), + supports_confirmation: invite.supports_confirmation(), + }), + ) + .await?; + + OrganizationInviteLink::try_from(response) + } + + /// Refresh an existing invite link. + /// This generates a new code and secret. + #[deprecated( + note = "Use `create` or `refresh`, which returns an `OrganizationInviteLinkView`, instead" + )] + pub async fn refresh_invite_link( + &self, + organization_id: OrganizationId, + supports_confirmation: bool, + ) -> Result { + let invite = self + .make_invite(organization_id, supports_confirmation) + .await?; + + let response = self + .api_configurations + .api_client + .organization_invite_links_api() + .refresh( + organization_id.into(), + Some(RefreshOrganizationInviteLinkRequestModel { + invite: String::from(&invite), + supports_confirmation: invite.supports_confirmation(), + }), + ) + .await?; + + OrganizationInviteLink::try_from(response) + } + + /// Using the organization key, recovers the [`InviteSecret`] from the invite carried in the + /// given [`OrganizationInviteLink`] so an admin can reconstruct the invite link. + #[cfg_attr(feature = "wasm", wasm_bindgen(unchecked_return_type = "InviteSecret"))] + pub fn get_invite_secret( + &self, + organization_id: OrganizationId, + invite: Invite, + ) -> Result { + let mut ctx = self.key_store.context(); + let org_key = SymmetricKeySlotId::Organization(organization_id); + let invite_key = invite.unseal_invite_key_with_organization_key(org_key, &mut ctx)?; + let invite_secret = invite.get_invite_secret(invite_key, &mut ctx)?; + Ok(invite_secret) + } + + /// Creates a new organization invite link. + pub async fn create( + &self, + organization_id: OrganizationId, + allowed_domains: Vec, + supports_confirmation: bool, + ) -> Result { + if allowed_domains.is_empty() { + return Err(InviteLinkError::NoAllowedDomains); + } + + let link = self + .create_invite_link(organization_id, allowed_domains, supports_confirmation) + .await?; + + let mut ctx = self.key_store.context(); + link.to_view(&mut ctx) + } + + /// Refreshes an existing invite link. + pub async fn refresh( + &self, + organization_id: OrganizationId, + supports_confirmation: bool, + ) -> Result { + let link = self + .refresh_invite_link(organization_id, supports_confirmation) + .await?; + + let mut ctx = self.key_store.context(); + link.to_view(&mut ctx) + } + + /// Updates the allowed domains for an existing organization invite link. + pub async fn update_allowed_domains( + &self, + organization_id: OrganizationId, + allowed_domains: Vec, + ) -> Result { + if allowed_domains.is_empty() { + return Err(InviteLinkError::NoAllowedDomains); + } + + let response = self + .api_configurations + .api_client + .organization_invite_links_api() + .update( + organization_id.into(), + Some(UpdateOrganizationInviteLinkRequestModel { allowed_domains }), + ) + .await?; + + let mut ctx = self.key_store.context(); + OrganizationInviteLink::try_from(response)?.to_view(&mut ctx) + } + + /// Helper function to make a new Invite to be included in a request model. + async fn make_invite( + &self, + organization_id: OrganizationId, + supports_confirmation: bool, + ) -> Result { + let wrapped_private_key_response = self + .api_configurations + .api_client + .organizations_api() + .get_private_key(organization_id.into()) + .await?; + + let wrapped_private_key: EncString = + require!(wrapped_private_key_response.private_key).parse()?; + + let mut ctx = self.key_store.context(); + let org_key = SymmetricKeySlotId::Organization(organization_id); + let (_, mut invite) = + Invite::make_for_private_key(org_key, &wrapped_private_key, &mut ctx)?; + + // Invites support confirmation by default; disable if not applicable + if !supports_confirmation { + invite.disable_confirmation(); + } + + Ok(invite) + } +} + +#[cfg(test)] +#[allow(deprecated)] +mod tests { + use bitwarden_api_api::{apis::ApiClient, models::OrganizationInviteLinkResponseModel}; + use bitwarden_core::{ + Client, client::ApiConfigurations, key_management::create_test_crypto_with_user_and_org_key, + }; + use bitwarden_crypto::{ + PublicKeyEncryptionAlgorithm, SymmetricCryptoKey, SymmetricKeyAlgorithm, + }; + use bitwarden_encoding::B64; + + use super::*; + use crate::InviteLinkClientExt as _; + + fn make_client(org_id: OrganizationId, api_client: ApiClient) -> InviteLinkAdminClient { + let user_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac); + let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac); + let key_store = create_test_crypto_with_user_and_org_key(user_key, org_id, org_key); + InviteLinkAdminClient { + key_store, + api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)), + } + } + + /// Wraps a fresh private key under the client's organization key and returns the serialized + /// [`EncString`], matching what the server's `get_private_key` endpoint would return. + fn wrapped_org_private_key(client: &InviteLinkAdminClient, org_id: OrganizationId) -> String { + let mut ctx = client.key_store.context(); + let org_key = SymmetricKeySlotId::Organization(org_id); + let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + ctx.wrap_private_key(org_key, private_key) + .unwrap() + .to_string() + } + + /// Builds the response model an invite-links `create`/`refresh` endpoint would return, echoing + /// the posted invite back so it can be parsed into an [`OrganizationInviteLink`]. + fn echo_link_response( + org_id: uuid::Uuid, + allowed_domains: Vec, + invite: String, + supports_confirmation: bool, + ) -> OrganizationInviteLinkResponseModel { + OrganizationInviteLinkResponseModel { + object: None, + id: Some(uuid::Uuid::new_v4()), + code: Some(uuid::Uuid::new_v4()), + organization_id: Some(org_id), + allowed_domains: Some(allowed_domains), + invite: Some(invite), + supports_confirmation: Some(supports_confirmation), + creation_date: Some("2024-01-01T00:00:00Z".to_string()), + } + } + + /// Regenerates the invite-link fixtures used by the WASM integration tests in + /// `crates/bitwarden-wasm-internal/integration-tests/tests/org-fixtures.ts`. All five values + /// belong together — the invites bind the thumbprint of the public key of the private key they + /// wrap — so they must always be copied over as a set. + #[tokio::test] + #[ignore = "Manual test to generate integration-test fixtures"] + async fn generate_integration_test_fixtures() { + let org_id: OrganizationId = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(); + let core = Client::init_test_account( + bitwarden_core::client::test_accounts::test_bitwarden_com_account(), + ) + .await; + let client = core.invite_link().admin(); + + let mut ctx = client.key_store.context(); + let org_key = SymmetricKeySlotId::Organization(org_id); + let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + let public_key = B64::from( + ctx.get_public_key(private_key) + .unwrap() + .to_der() + .unwrap() + .as_ref(), + ); + let wrapped = ctx.wrap_private_key(org_key, private_key).unwrap(); + let (secret, invite) = Invite::make_for_private_key(org_key, &wrapped, &mut ctx).unwrap(); + + // The same invite with the organization-key envelope stripped, which drives the acceptance + // (rather than self-confirmation) branch. It shares the invite secret and the bound + // public-key thumbprint, so one secret and one public key serve both invites. + let mut no_confirmation = invite.clone(); + no_confirmation.disable_confirmation(); + assert!(invite.supports_confirmation() && !no_confirmation.supports_confirmation()); + + println!("TEST_ORG_WRAPPED_PRIVATE_KEY = {}", wrapped.to_string()); + println!("TEST_ORG_PUBLIC_KEY = {public_key}"); + println!("TEST_INVITE = {}", String::from(&invite)); + println!( + "TEST_INVITE_NO_CONFIRMATION = {}", + String::from(&no_confirmation) + ); + println!("TEST_INVITE_SECRET = {}", String::from(&secret)); + } + + #[tokio::test] + async fn create_posts_and_returns_link_without_confirmation() { + let org_id = OrganizationId::new_v4(); + let wrapped = Arc::new(std::sync::Mutex::new(None::)); + let for_mock = wrapped.clone(); + let client = make_client( + org_id, + ApiClient::new_mocked(move |mock| { + mock.organizations_api + .expect_get_private_key() + .returning(move |_org| { + Ok( + bitwarden_api_api::models::OrganizationPrivateKeyResponseModel { + object: None, + private_key: for_mock.lock().unwrap().clone(), + }, + ) + }) + .once(); + mock.organization_invite_links_api + .expect_create() + .returning(|org, model| { + let model = model.unwrap(); + Ok(echo_link_response( + org, + model.allowed_domains, + model.invite, + model.supports_confirmation, + )) + }) + .once(); + }), + ); + *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id)); + + let link = client + .create(org_id, vec!["example.com".to_string()], false) + .await + .unwrap(); + + assert_eq!(link.allowed_domains, vec!["example.com".to_string()]); + assert!(!link.supports_confirmation); + } + + #[tokio::test] + async fn create_posts_and_returns_link_with_confirmation() { + let org_id = OrganizationId::new_v4(); + let wrapped = Arc::new(std::sync::Mutex::new(None::)); + let for_mock = wrapped.clone(); + let client = make_client( + org_id, + ApiClient::new_mocked(move |mock| { + mock.organizations_api + .expect_get_private_key() + .returning(move |_org| { + Ok( + bitwarden_api_api::models::OrganizationPrivateKeyResponseModel { + object: None, + private_key: for_mock.lock().unwrap().clone(), + }, + ) + }) + .once(); + mock.organization_invite_links_api + .expect_create() + .returning(|org, model| { + let model = model.unwrap(); + Ok(echo_link_response( + org, + model.allowed_domains, + model.invite, + model.supports_confirmation, + )) + }) + .once(); + }), + ); + *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id)); + + let link = client + .create(org_id, vec!["example.com".to_string()], true) + .await + .unwrap(); + + assert_eq!(link.allowed_domains, vec!["example.com".to_string()]); + assert!(link.supports_confirmation); + } + + #[tokio::test] + async fn create_builds_url_fragment_from_org_code_and_secret() { + let org_id = OrganizationId::new_v4(); + let code = uuid::Uuid::new_v4(); + let wrapped = Arc::new(std::sync::Mutex::new(None::)); + let for_mock = wrapped.clone(); + let client = make_client( + org_id, + ApiClient::new_mocked(move |mock| { + mock.organizations_api + .expect_get_private_key() + .returning(move |_org| { + Ok( + bitwarden_api_api::models::OrganizationPrivateKeyResponseModel { + object: None, + private_key: for_mock.lock().unwrap().clone(), + }, + ) + }) + .once(); + mock.organization_invite_links_api + .expect_create() + .returning(move |org, model| { + let model = model.unwrap(); + // Pin the code so the fragment's middle segment is deterministic. + let mut response = echo_link_response( + org, + model.allowed_domains, + model.invite, + model.supports_confirmation, + ); + response.code = Some(code); + Ok(response) + }) + .once(); + }), + ); + *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id)); + + let link = client + .create(org_id, vec!["example.com".to_string()], false) + .await + .unwrap(); + + // Fragment shape: /join/{org}/{code}?key={secret}. The org id and server-issued code are + // deterministic; the trailing secret must be a real, parseable `InviteSecret`. + let prefix = format!("/join/{org_id}/{code}?key="); + let key = link + .url_fragment + .strip_prefix(&prefix) + .unwrap_or_else(|| panic!("unexpected fragment: {}", link.url_fragment)); + assert!(key.parse::().is_ok()); + } + + #[tokio::test] + async fn create_two_calls_produce_different_invites() { + let org_id = OrganizationId::new_v4(); + let wrapped = Arc::new(std::sync::Mutex::new(None::)); + let for_mock = wrapped.clone(); + let client = make_client( + org_id, + ApiClient::new_mocked(move |mock| { + mock.organizations_api + .expect_get_private_key() + .returning(move |_org| { + Ok( + bitwarden_api_api::models::OrganizationPrivateKeyResponseModel { + object: None, + private_key: for_mock.lock().unwrap().clone(), + }, + ) + }) + .times(2); + mock.organization_invite_links_api + .expect_create() + .returning(|org, model| { + let model = model.unwrap(); + Ok(echo_link_response( + org, + model.allowed_domains, + model.invite, + model.supports_confirmation, + )) + }) + .times(2); + }), + ); + *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id)); + + let link1 = client + .create(org_id, vec!["example.com".to_string()], false) + .await + .unwrap(); + let link2 = client + .create(org_id, vec!["example.com".to_string()], false) + .await + .unwrap(); + + assert_ne!(&link1.url_fragment, &link2.url_fragment); + } + + #[tokio::test] + async fn create_with_unknown_organization_id_fails() { + let org_id = OrganizationId::new_v4(); + let other_org_id = OrganizationId::new_v4(); + let wrapped = Arc::new(std::sync::Mutex::new(None::)); + let for_mock = wrapped.clone(); + let client = make_client( + org_id, + ApiClient::new_mocked(move |mock| { + mock.organizations_api + .expect_get_private_key() + .returning(move |_org| { + Ok( + bitwarden_api_api::models::OrganizationPrivateKeyResponseModel { + object: None, + private_key: for_mock.lock().unwrap().clone(), + }, + ) + }) + .once(); + }), + ); + // The wrapped key is bound to the client's own org key; unwrapping it under a different + // organization's key slot (which is absent from the store) must fail. + *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id)); + + let result = client + .create(other_org_id, vec![String::from("example.com")], false) + .await; + + assert!(matches!(result, Err(InviteLinkError::Invite(_)))); + } + + #[tokio::test] + async fn create_surfaces_api_errors() { + let org_id = OrganizationId::new_v4(); + let client = make_client( + org_id, + ApiClient::new_mocked(|mock| { + mock.organizations_api + .expect_get_private_key() + .returning(|_org| Err(std::io::Error::other("boom").into())); + }), + ); + + let result = client + .create(org_id, vec![String::from("example.com")], false) + .await; + + assert!(matches!(result, Err(InviteLinkError::Api(_)))); + } +} diff --git a/crates/bitwarden-organization-invite-link/src/invite_link_client.rs b/crates/bitwarden-organization-invite-link/src/invite_link_client.rs index f464ebbb7e..0e5a89730c 100644 --- a/crates/bitwarden-organization-invite-link/src/invite_link_client.rs +++ b/crates/bitwarden-organization-invite-link/src/invite_link_client.rs @@ -1,56 +1,21 @@ use std::sync::Arc; -use bitwarden_api_api::models::{ - AcceptOrganizationInviteLinkRequestModel, ConfirmOrganizationInviteLinkRequestModel, - CreateOrganizationInviteLinkRequestModel, GetOrganizationInviteRequestModel, - RefreshOrganizationInviteLinkRequestModel, -}; use bitwarden_core::{ - ApiError, Client, FromClient, MissingFieldError, OrganizationId, - client::ApiConfigurations, - key_management::{KeySlotIds, PrivateKeySlotId, SymmetricKeySlotId}, - require, -}; -use bitwarden_crypto::{ - CoseKeyThumbprintExt, CryptoError, EncString, KeyStore, PrimitiveEncryptable, PublicKey, - SpkiPublicKeyBytes, UnsignedSharedKey, + Client, FromClient, OrganizationId, client::ApiConfigurations, key_management::KeySlotIds, }; -use bitwarden_encoding::B64; -use bitwarden_error::bitwarden_error; -use bitwarden_organization_crypto::invite::{Invite, InviteKeyBundleError, InviteSecret}; -use thiserror::Error; +use bitwarden_crypto::KeyStore; +use bitwarden_organization_crypto::invite::{Invite, InviteSecret}; #[cfg(feature = "wasm")] use wasm_bindgen::prelude::wasm_bindgen; -use crate::OrganizationInviteLink; +use crate::{InviteLinkAdminClient, InviteLinkError, InviteLinkUserClient, OrganizationInviteLink}; -/// Errors returned from [`InviteLinkClient`] operations. -#[bitwarden_error(flat)] -#[derive(Debug, Error)] -pub enum InviteLinkError { - /// A cryptographic invite operation (creating, unsealing, or recovering the invite) failed. - #[error(transparent)] - Invite(#[from] InviteKeyBundleError), - /// A network request to the server failed. - #[error(transparent)] - Api(#[from] ApiError), - /// A low-level cryptographic operation (key wrapping, encapsulation, or public-key parsing) - /// failed. - #[error(transparent)] - Crypto(#[from] CryptoError), - /// A required field was missing from a server response. - #[error(transparent)] - MissingField(#[from] MissingFieldError), - /// A value was present but malformed and could not be parsed. - #[error("Failed to parse `{0}`")] - ParseFailure(&'static str), - /// The account-recovery public key returned by the server does not match the organization - /// public key bound into the invite. - #[error("Account recovery public key does not match the invite's bound organization key")] - RecoveryKeyMismatch, -} - -/// Client for organization invite link cryptographic and network operations. +/// Client for organization invite link operations. +/// +/// This is a thin entry point that exposes two focused sub-clients: [`InviteLinkAdminClient`] (via +/// [`admin`](Self::admin)) for administrator CRUD operations, and [`InviteLinkUserClient`] (via +/// [`user`](Self::user)) for invitee flows. The other methods on this type are deprecated redirects +/// kept for backwards compatibility. #[cfg_attr(feature = "wasm", wasm_bindgen)] #[derive(FromClient)] pub struct InviteLinkClient { @@ -58,87 +23,83 @@ pub struct InviteLinkClient { pub(crate) api_configurations: Arc, } +impl InviteLinkClient { + fn admin_client(&self) -> InviteLinkAdminClient { + InviteLinkAdminClient { + key_store: self.key_store.clone(), + api_configurations: self.api_configurations.clone(), + } + } + + fn user_client(&self) -> InviteLinkUserClient { + InviteLinkUserClient { + key_store: self.key_store.clone(), + api_configurations: self.api_configurations.clone(), + } + } +} + +// The deprecated redirects below call methods on the sub-clients (some of which are themselves +// deprecated), and the `wasm_bindgen`-generated shims call the redirects; both would otherwise emit +// deprecation warnings from generated code we cannot annotate individually. +#[allow(deprecated)] #[cfg_attr(feature = "wasm", wasm_bindgen)] impl InviteLinkClient { - /// Creates a new organization invite and posts it to the server, returning the full - /// [`OrganizationInviteLink`] persisted by the server. - /// - /// # Security - /// Only the sealed invite is posted to the server; the invite secret is never sent. Use - /// [`InviteLinkClient::get_invite_secret`] to recover the secret needed to reconstruct the - /// invite link. + /// Administrative (organization-key) invite link operations. + pub fn admin(&self) -> InviteLinkAdminClient { + self.admin_client() + } + + /// Invitee (user) invite link operations. + pub fn user(&self) -> InviteLinkUserClient { + self.user_client() + } + + /// Creates a new organization invite and posts it to the server. + #[deprecated(note = "Use `invite_link().admin().create(...)` instead")] pub async fn create_invite_link( &self, organization_id: OrganizationId, allowed_domains: Vec, supports_confirmation: bool, ) -> Result { - let invite = self - .make_invite(organization_id, supports_confirmation) - .await?; - - let response = self - .api_configurations - .api_client - .organization_invite_links_api() - .create( - organization_id.into(), - Some(CreateOrganizationInviteLinkRequestModel { - allowed_domains, - invite: String::from(&invite), - supports_confirmation: invite.supports_confirmation(), - }), - ) - .await?; - - OrganizationInviteLink::try_from(response) + self.admin_client() + .create_invite_link(organization_id, allowed_domains, supports_confirmation) + .await } /// Refresh an existing invite link. /// This generates a new code and secret. + #[deprecated(note = "Use `invite_link().admin().refresh(...)` instead")] pub async fn refresh_invite_link( &self, organization_id: OrganizationId, supports_confirmation: bool, ) -> Result { - let invite = self - .make_invite(organization_id, supports_confirmation) - .await?; - - let response = self - .api_configurations - .api_client - .organization_invite_links_api() - .refresh( - organization_id.into(), - Some(RefreshOrganizationInviteLinkRequestModel { - invite: String::from(&invite), - supports_confirmation: invite.supports_confirmation(), - }), - ) - .await?; - - OrganizationInviteLink::try_from(response) + self.admin_client() + .refresh_invite_link(organization_id, supports_confirmation) + .await } /// Using the organization key, recovers the [`InviteSecret`] from the invite carried in the /// given [`OrganizationInviteLink`] so an admin can reconstruct the invite link. + #[deprecated( + note = "Use `invite_link().admin().create(...)` or `invite_link().admin().refresh(...)`, which return an `OrganizationInviteLinkView`, instead" + )] #[cfg_attr(feature = "wasm", wasm_bindgen(unchecked_return_type = "InviteSecret"))] pub fn get_invite_secret( &self, organization_id: OrganizationId, invite: Invite, ) -> Result { - let mut ctx = self.key_store.context(); - let org_key = SymmetricKeySlotId::Organization(organization_id); - let invite_key = invite.unseal_invite_key_with_organization_key(org_key, &mut ctx)?; - let invite_secret = invite.get_invite_secret(invite_key, &mut ctx)?; - Ok(invite_secret) + self.admin_client() + .get_invite_secret(organization_id, invite) } /// Accepts an organization invite for the current user, optionally enrolling into account /// recovery (when `enroll_into_account_recovery` is set) and — when the invite supports /// confirmation — self-confirming. + #[deprecated(note = "Use `invite_link().user().accept_and_optionally_confirm(...)` instead")] pub async fn accept_and_optionally_confirm( &self, organization_id: OrganizationId, @@ -147,152 +108,18 @@ impl InviteLinkClient { default_collection_name: String, enroll_into_account_recovery: bool, ) -> Result<(), InviteLinkError> { - let code = - uuid::Uuid::parse_str(&code).map_err(|_| InviteLinkError::ParseFailure("code"))?; - - // When enrolling into account recovery, fetch the organization's public key (which is the - // account-recovery public key) from the server. - let recovery_public_key = if enroll_into_account_recovery { - let response = self - .api_configurations - .api_client - .organizations_api() - .get_public_key(&organization_id.to_string()) - .await?; - Some( - require!(response.public_key) - .parse::() - .map_err(|_| InviteLinkError::ParseFailure("public_key"))?, - ) - } else { - None - }; - - let invite_response = self - .api_configurations - .api_client - .organization_users_api() - .get_invite(Some(GetOrganizationInviteRequestModel { - organization_id: organization_id.into(), + self.user_client() + .accept_and_optionally_confirm( + organization_id, code, - })) - .await?; - - let invite: Invite = require!(invite_response.invite).parse()?; - - // Confine the (non-Send) key store context to a synchronous scope; it produces the owned - // request payload consumed after the `.await`s below. - let request = { - let mut ctx = self.key_store.context(); - - // Recover the invite key from the invite secret the invitee holds. - let invite_key = - invite.unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)?; - - // Enroll into account recovery when requested. Verify the account-recovery public key - // against the organization public-key thumbprint bound into the invite before - // enrolling: a substituted recovery key would not match, so the organization key cannot - // be captured by an attacker-supplied key. Then encapsulate the user key to it. - let reset_password_key = match &recovery_public_key { - Some(recovery_public_key) => { - let recovery_public_key = - PublicKey::from_der(&SpkiPublicKeyBytes::from(recovery_public_key))?; - let bound_thumbprint = - invite.get_public_key_thumbprint(invite_key, &mut ctx)?; - if bound_thumbprint != recovery_public_key.thumbprint()? { - return Err(InviteLinkError::RecoveryKeyMismatch); - } - Some( - UnsignedSharedKey::encapsulate( - SymmetricKeySlotId::User, - &recovery_public_key, - &ctx, - )? - .to_string(), - ) - } - None => None, - }; - - if invite.supports_confirmation() { - // Self-confirm: recover the organization key and encapsulate it to the user. - let org_key = invite.unseal_organization_key(invite_key, &mut ctx)?; - let user_public_key = ctx.get_public_key(PrivateKeySlotId::UserPrivateKey)?; - let org_user_key = - UnsignedSharedKey::encapsulate(org_key, &user_public_key, &ctx)?.to_string(); - let default_user_collection_name = default_collection_name - .encrypt(&mut ctx, org_key)? - .to_string(); - PendingPost::Confirm(ConfirmOrganizationInviteLinkRequestModel { - organization_id: organization_id.into(), - code, - org_user_key, - reset_password_key, - default_user_collection_name, - }) - } else { - PendingPost::Accept(AcceptOrganizationInviteLinkRequestModel { - organization_id: organization_id.into(), - code, - reset_password_key, - }) - } - }; - - let organization_users_api = self.api_configurations.api_client.organization_users_api(); - match request { - PendingPost::Confirm(model) => { - organization_users_api - .confirm_invite_link(Some(model)) - .await? - } - PendingPost::Accept(model) => { - organization_users_api - .accept_invite_link(Some(model)) - .await? - } - } - - Ok(()) - } - - /// Helper function to make a new Invite to be included in a request model. - async fn make_invite( - &self, - organization_id: OrganizationId, - supports_confirmation: bool, - ) -> Result { - let wrapped_private_key_response = self - .api_configurations - .api_client - .organizations_api() - .get_private_key(organization_id.into()) - .await?; - - let wrapped_private_key: EncString = - require!(wrapped_private_key_response.private_key).parse()?; - - let mut ctx = self.key_store.context(); - let org_key = SymmetricKeySlotId::Organization(organization_id); - let (_, mut invite) = - Invite::make_for_private_key(org_key, &wrapped_private_key, &mut ctx)?; - - // Invites support confirmation by default; disable if not applicable - if !supports_confirmation { - invite.disable_confirmation(); - } - - Ok(invite) + invite_secret, + default_collection_name, + enroll_into_account_recovery, + ) + .await } } -/// A prepared invite acceptance request, built while the key store context is held and posted once -/// it has been dropped. -enum PendingPost { - Confirm(ConfirmOrganizationInviteLinkRequestModel), - Accept(AcceptOrganizationInviteLinkRequestModel), -} - /// Extension trait that exposes [`InviteLinkClient`] on [`Client`]. pub trait InviteLinkClientExt { /// Returns an [`InviteLinkClient`] @@ -306,20 +133,18 @@ impl InviteLinkClientExt for Client { } #[cfg(test)] +#[allow(deprecated)] mod tests { + use std::sync::Arc; + use bitwarden_api_api::{ apis::ApiClient, - models::{ - OrganizationInviteLinkResponseModel, OrganizationInviteResponseModel, - OrganizationPrivateKeyResponseModel, OrganizationPublicKeyResponseModel, - }, + models::{OrganizationInviteLinkResponseModel, OrganizationPrivateKeyResponseModel}, }; use bitwarden_core::{ client::ApiConfigurations, key_management::create_test_crypto_with_user_and_org_key, }; - use bitwarden_crypto::{ - PublicKeyEncryptionAlgorithm, SymmetricCryptoKey, SymmetricKeyAlgorithm, - }; + use bitwarden_crypto::{SymmetricCryptoKey, SymmetricKeyAlgorithm}; use super::*; @@ -327,118 +152,19 @@ mod tests { let user_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac); let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac); let key_store = create_test_crypto_with_user_and_org_key(user_key, org_id, org_key); - // Give the store a user private key so the confirmation branch can derive a user public - // key. - { - let mut ctx = key_store.context_mut(); - let local = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1); - ctx.persist_private_key(local, PrivateKeySlotId::UserPrivateKey) - .expect("persisting the user private key should work"); - } InviteLinkClient { key_store, api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)), } } - /// Wraps a fresh private key under the client's organization key and returns the serialized - /// [`EncString`], matching what the server's `get_private_key` endpoint would return. - fn wrapped_org_private_key(client: &InviteLinkClient, org_id: OrganizationId) -> String { - let mut ctx = client.key_store.context(); - let org_key = SymmetricKeySlotId::Organization(org_id); - let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1); - ctx.wrap_private_key(org_key, private_key) - .unwrap() - .to_string() - } - - /// Builds the response model an invite-links `create`/`refresh` endpoint would return, echoing - /// the posted invite back so it can be parsed into an [`OrganizationInviteLink`]. - fn echo_link_response( - org_id: uuid::Uuid, - allowed_domains: Vec, - invite: String, - supports_confirmation: bool, - ) -> OrganizationInviteLinkResponseModel { - OrganizationInviteLinkResponseModel { - object: None, - id: Some(uuid::Uuid::new_v4()), - code: Some(uuid::Uuid::new_v4()), - organization_id: Some(org_id), - allowed_domains: Some(allowed_domains), - invite: Some(invite), - supports_confirmation: Some(supports_confirmation), - creation_date: Some("2024-01-01T00:00:00Z".to_string()), - } - } - - /// Builds an invite + its secret and the organization public key it binds, all consistent with - /// the client's org key. - fn build_invite( - client: &InviteLinkClient, - org_id: OrganizationId, - ) -> (InviteSecret, Invite, B64) { - let mut ctx = client.key_store.context(); - let org_key = SymmetricKeySlotId::Organization(org_id); - let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1); - let org_public_key = B64::from( - ctx.get_public_key(private_key) - .unwrap() - .to_der() - .unwrap() - .as_ref(), - ); - let wrapped = ctx.wrap_private_key(org_key, private_key).unwrap(); - let (secret, invite) = Invite::make_for_private_key(org_key, &wrapped, &mut ctx).unwrap(); - (secret, invite, org_public_key) - } - - /// Regenerates the invite-link fixtures used by the WASM integration tests in - /// `crates/bitwarden-wasm-internal/integration-tests/tests/org-fixtures.ts`. All five values - /// belong together — the invites bind the thumbprint of the public key of the private key they - /// wrap — so they must always be copied over as a set. + /// The deprecated `create_invite_link` redirect on the parent client must still route through + /// to the admin sub-client and post a link, preserving backwards compatibility. #[tokio::test] - #[ignore = "Manual test to generate integration-test fixtures"] - async fn generate_integration_test_fixtures() { - let org_id: OrganizationId = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(); - let core = Client::init_test_account( - bitwarden_core::client::test_accounts::test_bitwarden_com_account(), - ) - .await; - let client = core.invite_link(); + async fn create_invite_link_redirects_to_admin_client() { + use bitwarden_core::key_management::SymmetricKeySlotId; + use bitwarden_crypto::PublicKeyEncryptionAlgorithm; - let mut ctx = client.key_store.context(); - let org_key = SymmetricKeySlotId::Organization(org_id); - let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1); - let public_key = B64::from( - ctx.get_public_key(private_key) - .unwrap() - .to_der() - .unwrap() - .as_ref(), - ); - let wrapped = ctx.wrap_private_key(org_key, private_key).unwrap(); - let (secret, invite) = Invite::make_for_private_key(org_key, &wrapped, &mut ctx).unwrap(); - - // The same invite with the organization-key envelope stripped, which drives the acceptance - // (rather than self-confirmation) branch. It shares the invite secret and the bound - // public-key thumbprint, so one secret and one public key serve both invites. - let mut no_confirmation = invite.clone(); - no_confirmation.disable_confirmation(); - assert!(invite.supports_confirmation() && !no_confirmation.supports_confirmation()); - - println!("TEST_ORG_WRAPPED_PRIVATE_KEY = {}", wrapped.to_string()); - println!("TEST_ORG_PUBLIC_KEY = {public_key}"); - println!("TEST_INVITE = {}", String::from(&invite)); - println!( - "TEST_INVITE_NO_CONFIRMATION = {}", - String::from(&no_confirmation) - ); - println!("TEST_INVITE_SECRET = {}", String::from(&secret)); - } - - #[tokio::test] - async fn create_invite_link_posts_and_returns_link_without_confirmation() { let org_id = OrganizationId::new_v4(); let wrapped = Arc::new(std::sync::Mutex::new(None::)); let for_mock = wrapped.clone(); @@ -458,364 +184,38 @@ mod tests { .expect_create() .returning(|org, model| { let model = model.unwrap(); - Ok(echo_link_response( - org, - model.allowed_domains, - model.invite, - model.supports_confirmation, - )) - }) - .once(); - }), - ); - *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id)); - - let link = client - .create_invite_link(org_id, vec!["example.com".to_string()], false) - .await - .unwrap(); - - assert_eq!(link.allowed_domains, vec!["example.com".to_string()]); - assert!(!String::from(&link.invite).is_empty()); - assert!(!link.invite.supports_confirmation()); - } - - #[tokio::test] - async fn create_invite_link_posts_and_returns_link_with_confirmation() { - let org_id = OrganizationId::new_v4(); - let wrapped = Arc::new(std::sync::Mutex::new(None::)); - let for_mock = wrapped.clone(); - let client = make_client( - org_id, - ApiClient::new_mocked(move |mock| { - mock.organizations_api - .expect_get_private_key() - .returning(move |_org| { - Ok(OrganizationPrivateKeyResponseModel { + Ok(OrganizationInviteLinkResponseModel { object: None, - private_key: for_mock.lock().unwrap().clone(), + id: Some(uuid::Uuid::new_v4()), + code: Some(uuid::Uuid::new_v4()), + organization_id: Some(org), + allowed_domains: Some(model.allowed_domains), + invite: Some(model.invite), + supports_confirmation: Some(model.supports_confirmation), + creation_date: Some("2024-01-01T00:00:00Z".to_string()), }) }) .once(); - mock.organization_invite_links_api - .expect_create() - .returning(|org, model| { - let model = model.unwrap(); - Ok(echo_link_response( - org, - model.allowed_domains, - model.invite, - model.supports_confirmation, - )) - }) - .once(); }), ); - *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id)); - - let link = client - .create_invite_link(org_id, vec!["example.com".to_string()], true) - .await - .unwrap(); - assert_eq!(link.allowed_domains, vec!["example.com".to_string()]); - assert!(!String::from(&link.invite).is_empty()); - assert!(link.invite.supports_confirmation()); - } - - #[tokio::test] - async fn create_invite_link_two_calls_produce_different_invites() { - let org_id = OrganizationId::new_v4(); - let wrapped = Arc::new(std::sync::Mutex::new(None::)); - let for_mock = wrapped.clone(); - let client = make_client( - org_id, - ApiClient::new_mocked(move |mock| { - mock.organizations_api - .expect_get_private_key() - .returning(move |_org| { - Ok(OrganizationPrivateKeyResponseModel { - object: None, - private_key: for_mock.lock().unwrap().clone(), - }) - }) - .times(2); - mock.organization_invite_links_api - .expect_create() - .returning(|org, model| { - let model = model.unwrap(); - Ok(echo_link_response( - org, - model.allowed_domains, - model.invite, - model.supports_confirmation, - )) - }) - .times(2); - }), - ); - *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id)); - - let link1 = client - .create_invite_link(org_id, vec![], false) - .await - .unwrap(); - let link2 = client - .create_invite_link(org_id, vec![], false) - .await - .unwrap(); - - assert_ne!(String::from(&link1.invite), String::from(&link2.invite)); - } - - #[tokio::test] - async fn create_invite_link_with_unknown_organization_id_fails() { - let org_id = OrganizationId::new_v4(); - let other_org_id = OrganizationId::new_v4(); - let wrapped = Arc::new(std::sync::Mutex::new(None::)); - let for_mock = wrapped.clone(); - let client = make_client( - org_id, - ApiClient::new_mocked(move |mock| { - mock.organizations_api - .expect_get_private_key() - .returning(move |_org| { - Ok(OrganizationPrivateKeyResponseModel { - object: None, - private_key: for_mock.lock().unwrap().clone(), - }) - }) - .once(); - }), - ); - // The wrapped key is bound to the client's own org key; unwrapping it under a different - // organization's key slot (which is absent from the store) must fail. - *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id)); - - let result = client.create_invite_link(other_org_id, vec![], false).await; - - assert!(matches!(result, Err(InviteLinkError::Invite(_)))); - } - - #[tokio::test] - async fn create_invite_link_surfaces_api_errors() { - let org_id = OrganizationId::new_v4(); - let client = make_client( - org_id, - ApiClient::new_mocked(|mock| { - mock.organizations_api - .expect_get_private_key() - .returning(|_org| Err(std::io::Error::other("boom").into())); - }), - ); - - let result = client.create_invite_link(org_id, vec![], false).await; - - assert!(matches!(result, Err(InviteLinkError::Api(_)))); - } - - #[tokio::test] - async fn get_invite_secret_round_trips_to_the_invite_secret() { - let org_id = OrganizationId::new_v4(); - let client = make_client(org_id, ApiClient::new_mocked(|_| {})); - - // A valid invite for the org must yield a non-empty secret recovered via the org key. - let (_secret, invite, _org_public_key) = build_invite(&client, org_id); - let secret = client.get_invite_secret(org_id, invite).unwrap(); - assert!(!String::from(&secret).is_empty()); - } - - #[tokio::test] - async fn accept_and_confirm_succeeds_for_confirmable_invite() { - let org_id = OrganizationId::new_v4(); - // `get_public_key` returns the base64 key held in this cell, and `get_invite` returns the - // serialized invite; both are filled after the invite is generated below. - let recovery = Arc::new(std::sync::Mutex::new(None::)); - let invite_cell = Arc::new(std::sync::Mutex::new(None::)); - let recovery_mock = recovery.clone(); - let invite_mock = invite_cell.clone(); - let client = make_client( - org_id, - ApiClient::new_mocked(move |mock| { - mock.organizations_api - .expect_get_public_key() - .returning(move |_id| { - Ok(OrganizationPublicKeyResponseModel { - object: None, - public_key: recovery_mock.lock().unwrap().clone(), - }) - }) - .once(); - mock.organization_users_api - .expect_get_invite() - .returning(move |_model| { - Ok(OrganizationInviteResponseModel { - invite: invite_mock.lock().unwrap().clone(), - }) - }) - .once(); - mock.organization_users_api - .expect_confirm_invite_link() - .returning(|_model| Ok(())) - .once(); - }), - ); - - let (secret, invite, org_public_key) = build_invite(&client, org_id); - assert!(invite.supports_confirmation()); - // The recovery public key returned by the "server" matches the invite's bound org key. - *recovery.lock().unwrap() = Some(String::from(&org_public_key)); - *invite_cell.lock().unwrap() = Some(String::from(&invite)); - - client - .accept_and_optionally_confirm( - org_id, - uuid::Uuid::new_v4().to_string(), - secret, - "Default".to_string(), - true, - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn accept_without_enrollment_confirms_without_recovery_key() { - let org_id = OrganizationId::new_v4(); - // Without enrollment the recovery key is never fetched, so only `get_invite` and - // `confirm_invite_link` run. - let invite_cell = Arc::new(std::sync::Mutex::new(None::)); - let invite_mock = invite_cell.clone(); - let client = make_client( - org_id, - ApiClient::new_mocked(move |mock| { - mock.organization_users_api - .expect_get_invite() - .returning(move |_model| { - Ok(OrganizationInviteResponseModel { - invite: invite_mock.lock().unwrap().clone(), - }) - }) - .once(); - mock.organization_users_api - .expect_confirm_invite_link() - .returning(|_model| Ok(())) - .once(); - }), - ); - - let (secret, invite, _org_public_key) = build_invite(&client, org_id); - *invite_cell.lock().unwrap() = Some(String::from(&invite)); - client - .accept_and_optionally_confirm( - org_id, - uuid::Uuid::new_v4().to_string(), - secret, - "Default".to_string(), - false, - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn accept_without_confirmation_posts_acceptance() { - let org_id = OrganizationId::new_v4(); - let invite_cell = Arc::new(std::sync::Mutex::new(None::)); - let invite_mock = invite_cell.clone(); - let client = make_client( - org_id, - ApiClient::new_mocked(move |mock| { - mock.organization_users_api - .expect_get_invite() - .returning(move |_model| { - Ok(OrganizationInviteResponseModel { - invite: invite_mock.lock().unwrap().clone(), - }) - }) - .once(); - mock.organization_users_api - .expect_accept_invite_link() - .returning(|_model| Ok(())) - .once(); - }), - ); - - // An invite with confirmation disabled routes to the acceptance branch. - let (secret, mut invite, _org_public_key) = build_invite(&client, org_id); - invite.disable_confirmation(); - assert!(!invite.supports_confirmation()); - *invite_cell.lock().unwrap() = Some(String::from(&invite)); - - client - .accept_and_optionally_confirm( - org_id, - uuid::Uuid::new_v4().to_string(), - secret, - "Default".to_string(), - false, - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn accept_with_mismatched_recovery_key_fails() { - let org_id = OrganizationId::new_v4(); - let recovery = Arc::new(std::sync::Mutex::new(None::)); - let invite_cell = Arc::new(std::sync::Mutex::new(None::)); - let recovery_mock = recovery.clone(); - let invite_mock = invite_cell.clone(); - let client = make_client( - org_id, - ApiClient::new_mocked(move |mock| { - mock.organizations_api - .expect_get_public_key() - .returning(move |_id| { - Ok(OrganizationPublicKeyResponseModel { - object: None, - public_key: recovery_mock.lock().unwrap().clone(), - }) - }) - .once(); - mock.organization_users_api - .expect_get_invite() - .returning(move |_model| { - Ok(OrganizationInviteResponseModel { - invite: invite_mock.lock().unwrap().clone(), - }) - }) - .once(); - }), - ); - - let (secret, invite, _org_public_key) = build_invite(&client, org_id); - *invite_cell.lock().unwrap() = Some(String::from(&invite)); - // The "server" returns an unrelated public key that must not match the invite's bound - // thumbprint. - let unrelated_public_key = { + // Wrap a private key under the client's org key, mirroring the server's `get_private_key`. + let wrapped_key = { let mut ctx = client.key_store.context(); + let org_key = SymmetricKeySlotId::Organization(org_id); let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1); - B64::from( - ctx.get_public_key(private_key) - .unwrap() - .to_der() - .unwrap() - .as_ref(), - ) + ctx.wrap_private_key(org_key, private_key) + .unwrap() + .to_string() }; - *recovery.lock().unwrap() = Some(String::from(&unrelated_public_key)); + *wrapped.lock().unwrap() = Some(wrapped_key); - let result = client - .accept_and_optionally_confirm( - org_id, - uuid::Uuid::new_v4().to_string(), - secret, - "Default".to_string(), - true, - ) - .await; + let link = client + .create_invite_link(org_id, vec!["example.com".to_string()], false) + .await + .unwrap(); - assert!(matches!(result, Err(InviteLinkError::RecoveryKeyMismatch))); + assert_eq!(link.allowed_domains, vec!["example.com".to_string()]); + assert!(!link.supports_confirmation); } } diff --git a/crates/bitwarden-organization-invite-link/src/invite_link_user_client.rs b/crates/bitwarden-organization-invite-link/src/invite_link_user_client.rs new file mode 100644 index 0000000000..a85f311871 --- /dev/null +++ b/crates/bitwarden-organization-invite-link/src/invite_link_user_client.rs @@ -0,0 +1,533 @@ +use std::sync::Arc; + +use bitwarden_api_api::models::{ + AcceptOrganizationInviteLinkRequestModel, ConfirmOrganizationInviteLinkRequestModel, + GetOrganizationInviteLinkStatusRequestModel, GetOrganizationInviteRequestModel, + OrganizationInviteLinkValidateEmailDomainRequestModel, +}; +use bitwarden_core::{ + FromClient, OrganizationId, + client::ApiConfigurations, + key_management::{KeySlotIds, PrivateKeySlotId, SymmetricKeySlotId}, + require, +}; +use bitwarden_crypto::{ + CoseKeyThumbprintExt, KeyStore, PrimitiveEncryptable, PublicKey, SpkiPublicKeyBytes, + UnsignedSharedKey, +}; +use bitwarden_encoding::B64; +use bitwarden_organization_crypto::invite::{Invite, InviteSecret}; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::{InviteLinkError, OrganizationInviteLinkStatusView}; + +/// Client for organization invite link invitee (user) operations: checking link status, validating +/// email eligibility, and accepting or self-confirming an invite. +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[derive(FromClient)] +pub struct InviteLinkUserClient { + pub(crate) key_store: KeyStore, + pub(crate) api_configurations: Arc, +} + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl InviteLinkUserClient { + /// Retrieves the status of an invite link. + /// Used to verify basic availability before attempting to accept. + pub async fn get_status( + &self, + organization_id: OrganizationId, + code: String, + ) -> Result { + let code = + uuid::Uuid::parse_str(&code).map_err(|_| InviteLinkError::ParseFailure("code"))?; + + let response = self + .api_configurations + .api_client + .organization_invite_links_api() + .get_status(Some(GetOrganizationInviteLinkStatusRequestModel { + organization_id: organization_id.into(), + code, + })) + .await?; + + OrganizationInviteLinkStatusView::try_from(response) + } + + /// Returns whether the given email address is in the allowed domains for an invite link. + pub async fn is_email_allowed( + &self, + organization_id: OrganizationId, + code: String, + email: String, + ) -> Result { + let code = + uuid::Uuid::parse_str(&code).map_err(|_| InviteLinkError::ParseFailure("code"))?; + + let response = self + .api_configurations + .api_client + .organization_invite_links_api() + .validate_email_domain(Some( + OrganizationInviteLinkValidateEmailDomainRequestModel { + organization_id: organization_id.into(), + code, + email, + }, + )) + .await?; + + Ok(require!(response.is_allowed)) + } + + /// Accepts an organization invite for the current user, optionally enrolling into account + /// recovery (when `enroll_into_account_recovery` is set) and — when the invite supports + /// confirmation — self-confirming. + pub async fn accept_and_optionally_confirm( + &self, + organization_id: OrganizationId, + code: String, + invite_secret: InviteSecret, + default_collection_name: String, + enroll_into_account_recovery: bool, + ) -> Result<(), InviteLinkError> { + let code = + uuid::Uuid::parse_str(&code).map_err(|_| InviteLinkError::ParseFailure("code"))?; + + // When enrolling into account recovery, fetch the organization's public key (which is the + // account-recovery public key) from the server. + let recovery_public_key = if enroll_into_account_recovery { + let response = self + .api_configurations + .api_client + .organizations_api() + .get_public_key(&organization_id.to_string()) + .await?; + Some( + require!(response.public_key) + .parse::() + .map_err(|_| InviteLinkError::ParseFailure("public_key"))?, + ) + } else { + None + }; + + let invite_response = self + .api_configurations + .api_client + .organization_users_api() + .get_invite(Some(GetOrganizationInviteRequestModel { + organization_id: organization_id.into(), + code, + })) + .await?; + + let invite: Invite = require!(invite_response.invite).parse()?; + + // Confine the (non-Send) key store context to a synchronous scope; it produces the owned + // request payload consumed after the `.await`s below. + let request = { + let mut ctx = self.key_store.context(); + + // Recover the invite key from the invite secret the invitee holds. + let invite_key = + invite.unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)?; + + // Enroll into account recovery when requested. Verify the account-recovery public key + // against the organization public-key thumbprint bound into the invite before + // enrolling: a substituted recovery key would not match, so the organization key cannot + // be captured by an attacker-supplied key. Then encapsulate the user key to it. + let reset_password_key = match &recovery_public_key { + Some(recovery_public_key) => { + let recovery_public_key = + PublicKey::from_der(&SpkiPublicKeyBytes::from(recovery_public_key))?; + let bound_thumbprint = + invite.get_public_key_thumbprint(invite_key, &mut ctx)?; + if bound_thumbprint != recovery_public_key.thumbprint()? { + return Err(InviteLinkError::RecoveryKeyMismatch); + } + Some( + UnsignedSharedKey::encapsulate( + SymmetricKeySlotId::User, + &recovery_public_key, + &ctx, + )? + .to_string(), + ) + } + None => None, + }; + + if invite.supports_confirmation() { + // Self-confirm: recover the organization key and encapsulate it to the user. + let org_key = invite.unseal_organization_key(invite_key, &mut ctx)?; + let user_public_key = ctx.get_public_key(PrivateKeySlotId::UserPrivateKey)?; + let org_user_key = + UnsignedSharedKey::encapsulate(org_key, &user_public_key, &ctx)?.to_string(); + let default_user_collection_name = default_collection_name + .encrypt(&mut ctx, org_key)? + .to_string(); + PendingPost::Confirm(ConfirmOrganizationInviteLinkRequestModel { + organization_id: organization_id.into(), + code, + org_user_key, + reset_password_key, + default_user_collection_name, + }) + } else { + PendingPost::Accept(AcceptOrganizationInviteLinkRequestModel { + organization_id: organization_id.into(), + code, + reset_password_key, + }) + } + }; + + let organization_users_api = self.api_configurations.api_client.organization_users_api(); + match request { + PendingPost::Confirm(model) => { + organization_users_api + .confirm_invite_link(Some(model)) + .await? + } + PendingPost::Accept(model) => { + organization_users_api + .accept_invite_link(Some(model)) + .await? + } + } + + Ok(()) + } +} + +/// A prepared invite acceptance request, built while the key store context is held and posted once +/// it has been dropped. +enum PendingPost { + Confirm(ConfirmOrganizationInviteLinkRequestModel), + Accept(AcceptOrganizationInviteLinkRequestModel), +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::{ + apis::ApiClient, + models::{ + OrganizationInviteLinkSsoResponseModel, OrganizationInviteLinkStatusResponseModel, + OrganizationInviteLinkValidateEmailDomainResponseModel, + OrganizationInviteResponseModel, OrganizationPublicKeyResponseModel, + }, + }; + use bitwarden_core::{ + client::ApiConfigurations, key_management::create_test_crypto_with_user_and_org_key, + }; + use bitwarden_crypto::{ + PublicKeyEncryptionAlgorithm, SymmetricCryptoKey, SymmetricKeyAlgorithm, + }; + + use super::*; + + fn make_client(org_id: OrganizationId, api_client: ApiClient) -> InviteLinkUserClient { + let user_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac); + let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac); + let key_store = create_test_crypto_with_user_and_org_key(user_key, org_id, org_key); + // Give the store a user private key so the confirmation branch can derive a user public + // key. + { + let mut ctx = key_store.context_mut(); + let local = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + ctx.persist_private_key(local, PrivateKeySlotId::UserPrivateKey) + .expect("persisting the user private key should work"); + } + InviteLinkUserClient { + key_store, + api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)), + } + } + + /// Builds an invite + its secret and the organization public key it binds, all consistent with + /// the client's org key. + fn build_invite( + client: &InviteLinkUserClient, + org_id: OrganizationId, + ) -> (InviteSecret, Invite, B64) { + let mut ctx = client.key_store.context(); + let org_key = SymmetricKeySlotId::Organization(org_id); + let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + let org_public_key = B64::from( + ctx.get_public_key(private_key) + .unwrap() + .to_der() + .unwrap() + .as_ref(), + ); + let wrapped = ctx.wrap_private_key(org_key, private_key).unwrap(); + let (secret, invite) = Invite::make_for_private_key(org_key, &wrapped, &mut ctx).unwrap(); + (secret, invite, org_public_key) + } + + #[tokio::test] + async fn accept_and_confirm_succeeds_for_confirmable_invite() { + let org_id = OrganizationId::new_v4(); + // `get_public_key` returns the base64 key held in this cell, and `get_invite` returns the + // serialized invite; both are filled after the invite is generated below. + let recovery = Arc::new(std::sync::Mutex::new(None::)); + let invite_cell = Arc::new(std::sync::Mutex::new(None::)); + let recovery_mock = recovery.clone(); + let invite_mock = invite_cell.clone(); + let client = make_client( + org_id, + ApiClient::new_mocked(move |mock| { + mock.organizations_api + .expect_get_public_key() + .returning(move |_id| { + Ok(OrganizationPublicKeyResponseModel { + object: None, + public_key: recovery_mock.lock().unwrap().clone(), + }) + }) + .once(); + mock.organization_users_api + .expect_get_invite() + .returning(move |_model| { + Ok(OrganizationInviteResponseModel { + invite: invite_mock.lock().unwrap().clone(), + }) + }) + .once(); + mock.organization_users_api + .expect_confirm_invite_link() + .returning(|_model| Ok(())) + .once(); + }), + ); + + let (secret, invite, org_public_key) = build_invite(&client, org_id); + assert!(invite.supports_confirmation()); + // The recovery public key returned by the "server" matches the invite's bound org key. + *recovery.lock().unwrap() = Some(String::from(&org_public_key)); + *invite_cell.lock().unwrap() = Some(String::from(&invite)); + + client + .accept_and_optionally_confirm( + org_id, + uuid::Uuid::new_v4().to_string(), + secret, + "Default".to_string(), + true, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn accept_without_enrollment_confirms_without_recovery_key() { + let org_id = OrganizationId::new_v4(); + // Without enrollment the recovery key is never fetched, so only `get_invite` and + // `confirm_invite_link` run. + let invite_cell = Arc::new(std::sync::Mutex::new(None::)); + let invite_mock = invite_cell.clone(); + let client = make_client( + org_id, + ApiClient::new_mocked(move |mock| { + mock.organization_users_api + .expect_get_invite() + .returning(move |_model| { + Ok(OrganizationInviteResponseModel { + invite: invite_mock.lock().unwrap().clone(), + }) + }) + .once(); + mock.organization_users_api + .expect_confirm_invite_link() + .returning(|_model| Ok(())) + .once(); + }), + ); + + let (secret, invite, _org_public_key) = build_invite(&client, org_id); + *invite_cell.lock().unwrap() = Some(String::from(&invite)); + client + .accept_and_optionally_confirm( + org_id, + uuid::Uuid::new_v4().to_string(), + secret, + "Default".to_string(), + false, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn accept_without_confirmation_posts_acceptance() { + let org_id = OrganizationId::new_v4(); + let invite_cell = Arc::new(std::sync::Mutex::new(None::)); + let invite_mock = invite_cell.clone(); + let client = make_client( + org_id, + ApiClient::new_mocked(move |mock| { + mock.organization_users_api + .expect_get_invite() + .returning(move |_model| { + Ok(OrganizationInviteResponseModel { + invite: invite_mock.lock().unwrap().clone(), + }) + }) + .once(); + mock.organization_users_api + .expect_accept_invite_link() + .returning(|_model| Ok(())) + .once(); + }), + ); + + // An invite with confirmation disabled routes to the acceptance branch. + let (secret, mut invite, _org_public_key) = build_invite(&client, org_id); + invite.disable_confirmation(); + assert!(!invite.supports_confirmation()); + *invite_cell.lock().unwrap() = Some(String::from(&invite)); + + client + .accept_and_optionally_confirm( + org_id, + uuid::Uuid::new_v4().to_string(), + secret, + "Default".to_string(), + false, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn accept_with_mismatched_recovery_key_fails() { + let org_id = OrganizationId::new_v4(); + let recovery = Arc::new(std::sync::Mutex::new(None::)); + let invite_cell = Arc::new(std::sync::Mutex::new(None::)); + let recovery_mock = recovery.clone(); + let invite_mock = invite_cell.clone(); + let client = make_client( + org_id, + ApiClient::new_mocked(move |mock| { + mock.organizations_api + .expect_get_public_key() + .returning(move |_id| { + Ok(OrganizationPublicKeyResponseModel { + object: None, + public_key: recovery_mock.lock().unwrap().clone(), + }) + }) + .once(); + mock.organization_users_api + .expect_get_invite() + .returning(move |_model| { + Ok(OrganizationInviteResponseModel { + invite: invite_mock.lock().unwrap().clone(), + }) + }) + .once(); + }), + ); + + let (secret, invite, _org_public_key) = build_invite(&client, org_id); + *invite_cell.lock().unwrap() = Some(String::from(&invite)); + // The "server" returns an unrelated public key that must not match the invite's bound + // thumbprint. + let unrelated_public_key = { + let mut ctx = client.key_store.context(); + let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + B64::from( + ctx.get_public_key(private_key) + .unwrap() + .to_der() + .unwrap() + .as_ref(), + ) + }; + *recovery.lock().unwrap() = Some(String::from(&unrelated_public_key)); + + let result = client + .accept_and_optionally_confirm( + org_id, + uuid::Uuid::new_v4().to_string(), + secret, + "Default".to_string(), + true, + ) + .await; + + assert!(matches!(result, Err(InviteLinkError::RecoveryKeyMismatch))); + } + + #[tokio::test] + async fn get_status_returns_mapped_view() { + let org_id = OrganizationId::new_v4(); + let client = make_client( + org_id, + ApiClient::new_mocked(|mock| { + mock.organization_invite_links_api + .expect_get_status() + .returning(|_model| { + Ok(OrganizationInviteLinkStatusResponseModel { + object: None, + organization_name: Some("Test Org".to_string()), + links_enabled: Some(true), + seats_available: Some(true), + supports_confirmation: Some(true), + sso: Some(Box::new(OrganizationInviteLinkSsoResponseModel { + object: None, + org_sso_id: Some("sso-id".to_string()), + required: Some(true), + })), + }) + }) + .once(); + }), + ); + + let status = client + .get_status(org_id, uuid::Uuid::new_v4().to_string()) + .await + .unwrap(); + + assert_eq!(status.organization_name, "Test Org"); + assert!(status.links_enabled); + assert!(status.seats_available); + assert!(status.supports_confirmation); + let sso = status.sso.expect("sso should be present"); + assert_eq!(sso.org_sso_id.as_deref(), Some("sso-id")); + assert!(sso.required); + } + + #[tokio::test] + async fn is_email_allowed_returns_is_allowed() { + let org_id = OrganizationId::new_v4(); + let client = make_client( + org_id, + ApiClient::new_mocked(|mock| { + mock.organization_invite_links_api + .expect_validate_email_domain() + .returning(|_model| { + Ok(OrganizationInviteLinkValidateEmailDomainResponseModel { + is_allowed: Some(true), + }) + }) + .once(); + }), + ); + + let allowed = client + .is_email_allowed( + org_id, + uuid::Uuid::new_v4().to_string(), + "user@example.com".to_string(), + ) + .await + .unwrap(); + + assert!(allowed); + } +} diff --git a/crates/bitwarden-organization-invite-link/src/lib.rs b/crates/bitwarden-organization-invite-link/src/lib.rs index ffcb0cf9be..30626b8816 100644 --- a/crates/bitwarden-organization-invite-link/src/lib.rs +++ b/crates/bitwarden-organization-invite-link/src/lib.rs @@ -1,6 +1,15 @@ #![doc = include_str!("../README.md")] +mod error; +mod invite_link_admin_client; mod invite_link_client; +mod invite_link_user_client; mod organization_invite_link; -pub use invite_link_client::{InviteLinkClient, InviteLinkClientExt, InviteLinkError}; -pub use organization_invite_link::OrganizationInviteLink; +pub use error::InviteLinkError; +pub use invite_link_admin_client::InviteLinkAdminClient; +pub use invite_link_client::{InviteLinkClient, InviteLinkClientExt}; +pub use invite_link_user_client::InviteLinkUserClient; +pub use organization_invite_link::{ + OrganizationInviteLink, OrganizationInviteLinkSsoView, OrganizationInviteLinkStatusView, + OrganizationInviteLinkView, +}; diff --git a/crates/bitwarden-organization-invite-link/src/organization_invite_link.rs b/crates/bitwarden-organization-invite-link/src/organization_invite_link.rs index dd2efb95c6..158a2398f7 100644 --- a/crates/bitwarden-organization-invite-link/src/organization_invite_link.rs +++ b/crates/bitwarden-organization-invite-link/src/organization_invite_link.rs @@ -1,5 +1,13 @@ -use bitwarden_api_api::models::OrganizationInviteLinkResponseModel; -use bitwarden_core::{OrganizationId, require}; +use bitwarden_api_api::models::{ + OrganizationInviteLinkResponseModel, OrganizationInviteLinkSsoResponseModel, + OrganizationInviteLinkStatusResponseModel, +}; +use bitwarden_core::{ + OrganizationId, + key_management::{KeySlotIds, SymmetricKeySlotId}, + require, +}; +use bitwarden_crypto::KeyStoreContext; use bitwarden_organization_crypto::invite::Invite; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -47,3 +55,104 @@ impl TryFrom for OrganizationInviteLink { }) } } + +impl OrganizationInviteLink { + /// Converts the invite link to a view model with URL for display. + pub fn to_view( + self, + ctx: &mut KeyStoreContext, + ) -> Result { + let org_id = self.organization_id; + + // Unwrap the invite secret + let org_key = SymmetricKeySlotId::Organization(org_id); + let invite_key = self + .invite + .unseal_invite_key_with_organization_key(org_key, ctx)?; + let invite_secret = self.invite.get_invite_secret(invite_key, ctx)?; + + let invite_secret_str: String = (&invite_secret).into(); + let code = self.code; + let url_fragment = format!("/join/{org_id}/{code}?key={invite_secret_str}"); + + Ok(OrganizationInviteLinkView { + id: self.id, + organization_id: self.organization_id, + allowed_domains: self.allowed_domains, + supports_confirmation: self.supports_confirmation, + creation_date: self.creation_date, + url_fragment, + }) + } +} + +/// An organization invite link with reconstructed URL for display by the client. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "camelCase")] +pub struct OrganizationInviteLinkView { + /// Unique identifier of the invite link. + pub id: Uuid, + /// The organization this invite link belongs to. + pub organization_id: OrganizationId, + /// Email domains permitted to redeem this invite link. + pub allowed_domains: Vec, + /// Whether invitees can self-confirm using this invite link. + pub supports_confirmation: bool, + /// When the invite link was created. + pub creation_date: DateTime, + /// The invite link URL fragment (to be appended on the web vault URL) + pub url_fragment: String, +} + +/// The status of an organization invite link, used to verify basic availability before an invitee +/// attempts to accept. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "camelCase")] +pub struct OrganizationInviteLinkStatusView { + /// The name of the organization the invite link belongs to. + pub organization_name: String, + /// Whether invite links are currently enabled for the organization. + pub links_enabled: bool, + /// Whether the organization has seats available for new members. + pub seats_available: bool, + /// Whether invitees can self-confirm using this invite link. + pub supports_confirmation: bool, + /// SSO details for the organization, when SSO is configured. + pub sso: Option, +} + +/// SSO details for an organization referenced by an invite link status. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "camelCase")] +pub struct OrganizationInviteLinkSsoView { + /// The organization's SSO identifier, when configured. + pub org_sso_id: Option, + /// Whether SSO is required to redeem this invite link. + pub required: bool, +} + +impl From for OrganizationInviteLinkSsoView { + fn from(response: OrganizationInviteLinkSsoResponseModel) -> Self { + Self { + org_sso_id: response.org_sso_id, + required: response.required.unwrap_or(false), + } + } +} + +impl TryFrom for OrganizationInviteLinkStatusView { + type Error = InviteLinkError; + + fn try_from(response: OrganizationInviteLinkStatusResponseModel) -> Result { + Ok(Self { + organization_name: require!(response.organization_name), + links_enabled: response.links_enabled.unwrap_or(false), + seats_available: response.seats_available.unwrap_or(false), + supports_confirmation: response.supports_confirmation.unwrap_or(false), + sso: response.sso.map(|sso| (*sso).into()), + }) + } +}