Skip to main content

bitwarden_organization_invite_link/
invite_link_client.rs

1use bitwarden_core::{
2    Client, FromClient, OrganizationId,
3    key_management::{KeySlotIds, SymmetricKeySlotId},
4};
5use bitwarden_crypto::KeyStore;
6use bitwarden_error::bitwarden_error;
7use bitwarden_organization_crypto::{
8    InviteKeyBundle, InviteKeyBundleError, InviteKeyData, InviteKeyEnvelope,
9};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12#[cfg(feature = "wasm")]
13use tsify::Tsify;
14#[cfg(feature = "wasm")]
15use wasm_bindgen::prelude::wasm_bindgen;
16
17/// Errors returned from [`InviteLinkClient`] operations.
18#[bitwarden_error(flat)]
19#[derive(Debug, Error)]
20pub enum OrganizationInviteCryptoBundleError {
21    /// Failed to generate the invite key bundle.
22    #[error("Key bundle generation failed: {0}")]
23    BundleGenerationFailed(#[from] InviteKeyBundleError),
24    /// Failed to unseal the invite key envelope using the organization key.
25    #[error("Failed to unseal invite key: {0}")]
26    UnsealingFailed(InviteKeyBundleError),
27}
28
29/// The cryptographic bundle returned when generating an organization member invite link.
30///
31/// - `invite_key`: raw invite key encoded as base64Url. **MUST NOT be sent to the server.**
32/// - `sealed_invite_key_envelope`: invite key sealed with the organization key, serialized as an
33///   EncString. Safe to send to the server.
34#[derive(Clone, Serialize, Deserialize)]
35#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
36#[serde(rename_all = "camelCase")]
37pub struct OrganizationInviteCryptoBundle {
38    /// Raw invite key. CRITICAL: MUST NOT be sent to the server.
39    #[cfg_attr(feature = "wasm", tsify(type = "InviteKeyData"))]
40    pub invite_key: InviteKeyData,
41    /// Invite key sealed with the organization key. Safe to send to the server.
42    #[cfg_attr(feature = "wasm", tsify(type = "InviteKeyEnvelope"))]
43    pub sealed_invite_key_envelope: InviteKeyEnvelope,
44}
45
46/// Client for organization invite link cryptographic operations.
47#[cfg_attr(feature = "wasm", wasm_bindgen)]
48#[derive(FromClient)]
49pub struct InviteLinkClient {
50    pub(crate) key_store: KeyStore<KeySlotIds>,
51}
52
53#[cfg_attr(feature = "wasm", wasm_bindgen)]
54impl InviteLinkClient {
55    /// Generates a new [`OrganizationInviteCryptoBundle`] sealed with the organization's key.
56    ///
57    /// The organization key is looked up from the client's key store via
58    /// [`SymmetricKeySlotId::Organization`]; the caller does not need to provide it directly.
59    ///
60    /// Each call produces a unique invite key sampled from a secure cryptographic source.
61    ///
62    /// # Security
63    /// The returned `invite_key` MUST NOT be sent to the server.
64    pub fn generate_invite_crypto_bundle(
65        &self,
66        organization_id: OrganizationId,
67    ) -> Result<OrganizationInviteCryptoBundle, OrganizationInviteCryptoBundleError> {
68        let mut ctx = self.key_store.context();
69        let org_key = SymmetricKeySlotId::Organization(organization_id);
70        let bundle = InviteKeyBundle::make(org_key, &mut ctx)?;
71        Ok(OrganizationInviteCryptoBundle {
72            invite_key: bundle.dangerous_get_raw_invite_key().clone(),
73            sealed_invite_key_envelope: bundle.get_sealed_invite_key_envelope().clone(),
74        })
75    }
76
77    /// Unseals a `sealed_invite_key_envelope` using the organization's key, returning the raw
78    /// invite key as [`InviteKeyData`].
79    pub fn unseal_invite_key(
80        &self,
81        organization_id: OrganizationId,
82        sealed_invite_key_envelope: InviteKeyEnvelope,
83    ) -> Result<InviteKeyData, OrganizationInviteCryptoBundleError> {
84        let mut ctx = self.key_store.context();
85        let org_key = SymmetricKeySlotId::Organization(organization_id);
86        sealed_invite_key_envelope
87            .unseal(org_key, &mut ctx)
88            .map_err(OrganizationInviteCryptoBundleError::UnsealingFailed)
89    }
90}
91
92/// Extension trait that exposes [`InviteLinkClient`] on [`Client`].
93pub trait InviteLinkClientExt {
94    /// Returns an [`InviteLinkClient`] backed by this client's key store.
95    fn invite_link(&self) -> InviteLinkClient;
96}
97
98impl InviteLinkClientExt for Client {
99    fn invite_link(&self) -> InviteLinkClient {
100        InviteLinkClient::from_client(self)
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use bitwarden_core::key_management::create_test_crypto_with_user_and_org_key;
107    use bitwarden_crypto::{SymmetricCryptoKey, SymmetricKeyAlgorithm};
108
109    use super::*;
110
111    fn make_client(org_id: OrganizationId) -> InviteLinkClient {
112        let user_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
113        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
114        let key_store = create_test_crypto_with_user_and_org_key(user_key, org_id, org_key);
115        InviteLinkClient { key_store }
116    }
117
118    #[test]
119    fn generate_invite_crypto_bundle_returns_non_empty_fields() {
120        let org_id = OrganizationId::new_v4();
121        let client = make_client(org_id);
122
123        let bundle = client.generate_invite_crypto_bundle(org_id).unwrap();
124
125        assert!(!String::from(&bundle.invite_key).is_empty());
126        assert!(!String::from(&bundle.sealed_invite_key_envelope).is_empty());
127    }
128
129    #[test]
130    fn envelope_unseals_to_raw_invite_key() {
131        let org_id = OrganizationId::new_v4();
132        let client = make_client(org_id);
133
134        let bundle = client.generate_invite_crypto_bundle(org_id).unwrap();
135        let unsealed = client
136            .unseal_invite_key(org_id, bundle.sealed_invite_key_envelope.clone())
137            .unwrap();
138
139        assert_eq!(bundle.invite_key, unsealed);
140    }
141
142    #[test]
143    fn two_calls_produce_different_invite_keys() {
144        let org_id = OrganizationId::new_v4();
145        let client = make_client(org_id);
146
147        let bundle1 = client.generate_invite_crypto_bundle(org_id).unwrap();
148        let bundle2 = client.generate_invite_crypto_bundle(org_id).unwrap();
149
150        assert_ne!(
151            String::from(&bundle1.invite_key),
152            String::from(&bundle2.invite_key)
153        );
154    }
155
156    #[test]
157    fn sealed_envelope_serializes_as_encstring_text_format() {
158        // The server validates EncryptedInviteKey as a Bitwarden EncString text
159        // format (e.g. "2.iv|data|mac").
160        let org_id = OrganizationId::new_v4();
161        let client = make_client(org_id);
162
163        let bundle = client.generate_invite_crypto_bundle(org_id).unwrap();
164        let envelope_str = String::from(&bundle.sealed_invite_key_envelope);
165
166        assert!(
167            envelope_str.parse::<bitwarden_crypto::EncString>().is_ok(),
168            "sealed_invite_key_envelope must parse as a valid EncString, got: {envelope_str}"
169        );
170    }
171
172    #[test]
173    fn unseal_with_wrong_organization_id_fails() {
174        let org_id = OrganizationId::new_v4();
175        let other_org_id = OrganizationId::new_v4();
176        let client = make_client(org_id);
177
178        let bundle = client.generate_invite_crypto_bundle(org_id).unwrap();
179        let result = client.unseal_invite_key(other_org_id, bundle.sealed_invite_key_envelope);
180
181        assert!(matches!(
182            result,
183            Err(OrganizationInviteCryptoBundleError::UnsealingFailed(_))
184        ));
185    }
186
187    #[test]
188    fn generate_with_unknown_organization_id_fails() {
189        let org_id = OrganizationId::new_v4();
190        let other_org_id = OrganizationId::new_v4();
191        let client = make_client(org_id);
192
193        let result = client.generate_invite_crypto_bundle(other_org_id);
194
195        assert!(matches!(
196            result,
197            Err(OrganizationInviteCryptoBundleError::BundleGenerationFailed(
198                _
199            ))
200        ));
201    }
202}