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