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