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
48impl SealedOpenOrgInviteData {
49    /// Seals an [`OpenOrgInvite`] into a [`SealedOpenOrgInviteData`] plus a freshly generated
50    /// [`HighEntropySecret`]. The caller must keep the secret client-side and place the sealed
51    /// data on the verification-email link; both halves are required to unseal.
52    pub fn seal(input: OpenOrgInvite) -> Result<(Self, HighEntropySecret), RegistrationError> {
53        // Per-call KeyStore — CEK never lives beyond this operation.
54        let key_store: KeyStore<KeySlotIds> = KeyStore::default();
55        let mut ctx = key_store.context_mut();
56
57        let high_entropy_secret = HighEntropySecret::make(OPEN_ORG_INVITE_SECRET_SIZE_BYTES)
58            .map_err(|_| RegistrationError::Crypto)?;
59
60        let versioned: RegistrationOpenOrgInviteData = RegistrationOpenOrgInviteDataV1 {
61            organization_id: input.organization_id,
62            invite_link_code: input.invite_link_code,
63            invite_secret: input.invite_secret,
64        }
65        .into();
66
67        let (data_envelope, cek_id) =
68            DataEnvelope::seal(versioned, &mut ctx).map_err(|_| RegistrationError::Crypto)?;
69
70        let key_envelope = SecretProtectedKeyEnvelope::seal(
71            cek_id,
72            &high_entropy_secret,
73            SecretProtectedKeyEnvelopeNamespace::RegistrationOpenOrgInvite,
74            &ctx,
75        )
76        .map_err(|_| RegistrationError::Crypto)?;
77
78        Ok((
79            SealedOpenOrgInviteData {
80                data_envelope,
81                key_envelope,
82            },
83            high_entropy_secret,
84        ))
85    }
86
87    /// Unseals a [`SealedOpenOrgInviteData`] back into an [`OpenOrgInvite`], given the paired
88    /// [`HighEntropySecret`] returned by [`Self::seal`]. Returns [`RegistrationError::Crypto`]
89    /// if the secret does not match the sealed payload or the payload has been tampered with.
90    pub fn unseal(&self, secret: &HighEntropySecret) -> Result<OpenOrgInvite, RegistrationError> {
91        // Per-call KeyStore — CEK never lives beyond this function.
92        let key_store: KeyStore<KeySlotIds> = KeyStore::default();
93        let mut ctx = key_store.context_mut();
94
95        let cek_id = self
96            .key_envelope
97            .unseal(
98                secret,
99                SecretProtectedKeyEnvelopeNamespace::RegistrationOpenOrgInvite,
100                &mut ctx,
101            )
102            .map_err(|_| RegistrationError::Crypto)?;
103
104        let versioned: RegistrationOpenOrgInviteData = self
105            .data_envelope
106            .unseal(cek_id, &mut ctx)
107            .map_err(|_| RegistrationError::Crypto)?;
108
109        // No post-decrypt equality check on the plaintext — the AES-GCM auth tag at each
110        // envelope layer is the substitution defense.
111        let RegistrationOpenOrgInviteData::RegistrationOpenOrgInviteDataV1(v1) = versioned;
112        Ok(OpenOrgInvite {
113            organization_id: v1.organization_id,
114            invite_link_code: v1.invite_link_code,
115            invite_secret: v1.invite_secret,
116        })
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn sample_input() -> OpenOrgInvite {
125        OpenOrgInvite {
126            organization_id: "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".to_string(),
127            invite_link_code: "abcd1234efgh5678".to_string(),
128            invite_secret: "raw-invite-secret-material-base64url".to_string(),
129        }
130    }
131
132    #[test]
133    fn seal_produces_populated_sealed_data_and_high_entropy_secret() {
134        let (sealed_data, high_entropy_secret) =
135            SealedOpenOrgInviteData::seal(sample_input()).expect("seal should succeed");
136
137        let wire = String::from(&sealed_data);
138        assert!(!wire.is_empty());
139        let parsed: SealedOpenOrgInviteData = wire.parse().expect("wire form must round-trip");
140        let _ = parsed.data_envelope;
141        let _ = parsed.key_envelope;
142
143        // High-entropy secret should also round-trip via its own wire form.
144        let secret_wire = String::from(high_entropy_secret);
145        assert!(!secret_wire.is_empty());
146        secret_wire
147            .parse::<HighEntropySecret>()
148            .expect("high_entropy_secret must be a valid wire string");
149    }
150
151    #[test]
152    fn two_seals_produce_distinct_secrets_and_data() {
153        let (first_data, first_secret) =
154            SealedOpenOrgInviteData::seal(sample_input()).expect("first seal should succeed");
155        let (second_data, second_secret) =
156            SealedOpenOrgInviteData::seal(sample_input()).expect("second seal should succeed");
157
158        // Per-registration randomness: fresh CEK + secret + HKDF salt.
159        assert_ne!(String::from(first_secret), String::from(second_secret));
160        assert_ne!(String::from(&first_data), String::from(&second_data));
161    }
162
163    #[test]
164    fn seal_unseal_round_trip_recovers_original_fields() {
165        let input = sample_input();
166        let (sealed_data, high_entropy_secret) =
167            SealedOpenOrgInviteData::seal(input.clone()).expect("seal should succeed");
168
169        let unsealed = sealed_data
170            .unseal(&high_entropy_secret)
171            .expect("unseal should succeed");
172
173        assert_eq!(unsealed, input);
174    }
175
176    #[test]
177    fn unseal_fails_with_wrong_high_entropy_secret() {
178        let (sealed_data, _) =
179            SealedOpenOrgInviteData::seal(sample_input()).expect("seal should succeed");
180        let unrelated = HighEntropySecret::make(OPEN_ORG_INVITE_SECRET_SIZE_BYTES).unwrap();
181
182        let err = sealed_data
183            .unseal(&unrelated)
184            .expect_err("unseal must reject an unrelated secret");
185        assert!(matches!(err, RegistrationError::Crypto));
186    }
187}