Skip to main content

bitwarden_auth/registration/open_org_invite_crypto/
open_org_invite.rs

1//! `OpenOrgInvite` and its sealed form `SealedOpenOrgInviteData`, and the seal/unseal
2//! operations between them. Seal returns the sealed blob paired with a `HighEntropySecret`.
3
4use bitwarden_core::key_management::KeySlotIds;
5use bitwarden_crypto::{
6    KeyStore,
7    safe::{
8        DataEnvelope, HighEntropySecret, SecretProtectedKeyEnvelope,
9        SecretProtectedKeyEnvelopeNamespace,
10    },
11};
12use serde::{Deserialize, Serialize};
13#[cfg(feature = "wasm")]
14use tsify::Tsify;
15
16use super::{RegistrationOpenOrgInviteData, data_v1::RegistrationOpenOrgInviteDataV1};
17use crate::registration::registration_client::RegistrationError;
18
19/// Byte length of the per-registration [`HighEntropySecret`] the seal path generates.
20pub(super) const OPEN_ORG_INVITE_SECRET_SIZE_BYTES: usize = 32;
21
22/// Plaintext open-organization-invite payload. Passed into
23/// [`crate::registration::registration_client::RegistrationClient::seal_open_org_invite_data`] to
24/// seal to be used in the registration email verification link, and returned by
25/// [`crate::registration::registration_client::RegistrationClient::unseal_open_org_invite_data`]
26/// for the acceptance flow.
27#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
28#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
29#[serde(rename_all = "camelCase")]
30pub struct OpenOrgInvite {
31    /// The organization the registrant is joining.
32    pub organization_id: String,
33    /// The public invite link code carried in the shared invite URL.
34    pub invite_link_code: String,
35    /// The invite secret associated with the invite link.
36    pub invite_secret: String,
37}
38
39/// The two sealed envelopes that together carry an open-organization-invite payload.
40#[derive(Debug, Clone)]
41pub struct SealedOpenOrgInviteData {
42    /// The OpenOrgInvite plaintext, encrypted under a fresh CEK.
43    pub(super) data_envelope: DataEnvelope,
44    /// The CEK, encrypted under the caller's HighEntropySecret.
45    pub(super) key_envelope: SecretProtectedKeyEnvelope,
46}
47
48// WASM ABI: `SealedOpenOrgInviteData` marshals as its wire string, matching the JSON wire form.
49#[cfg(feature = "wasm")]
50#[wasm_bindgen::prelude::wasm_bindgen(typescript_custom_section)]
51const TS_CUSTOM_TYPES: &'static str = r#"
52export type SealedOpenOrgInviteData = Tagged<string, "SealedOpenOrgInviteData">;
53"#;
54
55impl SealedOpenOrgInviteData {
56    /// Seals an [`OpenOrgInvite`] into a [`SealedOpenOrgInviteData`] plus a freshly generated
57    /// [`HighEntropySecret`]. The caller must keep the secret client-side and place the sealed
58    /// data on the verification-email link; both halves are required to unseal.
59    pub fn seal(input: OpenOrgInvite) -> Result<(Self, HighEntropySecret), RegistrationError> {
60        // Per-call KeyStore — CEK never lives beyond this operation.
61        let key_store: KeyStore<KeySlotIds> = KeyStore::default();
62        let mut ctx = key_store.context_mut();
63
64        let high_entropy_secret = HighEntropySecret::make(OPEN_ORG_INVITE_SECRET_SIZE_BYTES)
65            .map_err(|_| RegistrationError::Crypto)?;
66
67        let versioned: RegistrationOpenOrgInviteData = RegistrationOpenOrgInviteDataV1 {
68            organization_id: input.organization_id,
69            invite_link_code: input.invite_link_code,
70            invite_secret: input.invite_secret,
71        }
72        .into();
73
74        let (data_envelope, cek_id) =
75            DataEnvelope::seal(versioned, &mut ctx).map_err(|_| RegistrationError::Crypto)?;
76
77        let key_envelope = SecretProtectedKeyEnvelope::seal(
78            cek_id,
79            &high_entropy_secret,
80            SecretProtectedKeyEnvelopeNamespace::RegistrationOpenOrgInvite,
81            &ctx,
82        )
83        .map_err(|_| RegistrationError::Crypto)?;
84
85        Ok((
86            SealedOpenOrgInviteData {
87                data_envelope,
88                key_envelope,
89            },
90            high_entropy_secret,
91        ))
92    }
93
94    /// Unseals a [`SealedOpenOrgInviteData`] back into an [`OpenOrgInvite`], given the paired
95    /// [`HighEntropySecret`] returned by [`Self::seal`]. Returns [`RegistrationError::Crypto`]
96    /// if the secret does not match the sealed payload or the payload has been tampered with.
97    pub fn unseal(&self, secret: &HighEntropySecret) -> Result<OpenOrgInvite, RegistrationError> {
98        // Per-call KeyStore — CEK never lives beyond this function.
99        let key_store: KeyStore<KeySlotIds> = KeyStore::default();
100        let mut ctx = key_store.context_mut();
101
102        let cek_id = self
103            .key_envelope
104            .unseal(
105                secret,
106                SecretProtectedKeyEnvelopeNamespace::RegistrationOpenOrgInvite,
107                &mut ctx,
108            )
109            .map_err(|_| RegistrationError::Crypto)?;
110
111        let versioned: RegistrationOpenOrgInviteData = self
112            .data_envelope
113            .unseal(cek_id, &mut ctx)
114            .map_err(|_| RegistrationError::Crypto)?;
115
116        // No post-decrypt equality check on the plaintext — the AES-GCM auth tag at each
117        // envelope layer is the substitution defense.
118        let RegistrationOpenOrgInviteData::RegistrationOpenOrgInviteDataV1(v1) = versioned;
119        Ok(OpenOrgInvite {
120            organization_id: v1.organization_id,
121            invite_link_code: v1.invite_link_code,
122            invite_secret: v1.invite_secret,
123        })
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn sample_input() -> OpenOrgInvite {
132        OpenOrgInvite {
133            organization_id: "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".to_string(),
134            invite_link_code: "abcd1234efgh5678".to_string(),
135            invite_secret: "raw-invite-secret-material-base64url".to_string(),
136        }
137    }
138
139    #[test]
140    fn seal_produces_populated_sealed_data_and_high_entropy_secret() {
141        let (sealed_data, high_entropy_secret) =
142            SealedOpenOrgInviteData::seal(sample_input()).expect("seal should succeed");
143
144        let wire = String::from(&sealed_data);
145        assert!(!wire.is_empty());
146        let parsed: SealedOpenOrgInviteData = wire.parse().expect("wire form must round-trip");
147        let _ = parsed.data_envelope;
148        let _ = parsed.key_envelope;
149
150        // High-entropy secret should also round-trip via its own wire form.
151        let secret_wire = String::from(high_entropy_secret);
152        assert!(!secret_wire.is_empty());
153        secret_wire
154            .parse::<HighEntropySecret>()
155            .expect("high_entropy_secret must be a valid wire string");
156    }
157
158    #[test]
159    fn two_seals_produce_distinct_secrets_and_data() {
160        let (first_data, first_secret) =
161            SealedOpenOrgInviteData::seal(sample_input()).expect("first seal should succeed");
162        let (second_data, second_secret) =
163            SealedOpenOrgInviteData::seal(sample_input()).expect("second seal should succeed");
164
165        // Per-registration randomness: fresh CEK + secret + HKDF salt.
166        assert_ne!(String::from(first_secret), String::from(second_secret));
167        assert_ne!(String::from(&first_data), String::from(&second_data));
168    }
169
170    #[test]
171    fn seal_unseal_round_trip_recovers_original_fields() {
172        let input = sample_input();
173        let (sealed_data, high_entropy_secret) =
174            SealedOpenOrgInviteData::seal(input.clone()).expect("seal should succeed");
175
176        let unsealed = sealed_data
177            .unseal(&high_entropy_secret)
178            .expect("unseal should succeed");
179
180        assert_eq!(unsealed, input);
181    }
182
183    #[test]
184    fn unseal_fails_with_wrong_high_entropy_secret() {
185        let (sealed_data, _) =
186            SealedOpenOrgInviteData::seal(sample_input()).expect("seal should succeed");
187        let unrelated = HighEntropySecret::make(OPEN_ORG_INVITE_SECRET_SIZE_BYTES).unwrap();
188
189        let err = sealed_data
190            .unseal(&unrelated)
191            .expect_err("unseal must reject an unrelated secret");
192        assert!(matches!(err, RegistrationError::Crypto));
193    }
194}