Skip to main content

bitwarden_organization_crypto/
invite.rs

1//! Cryptographic organization invites
2//!
3//! An invite is built around an XAES-256-GCM **invite key** that acts as the hub tying every sealed
4//! object together. Two independent secrets can recover the invite key (the invite secret from the
5//! link, or the organization key held by admins), and from the invite key everything else — the
6//! invite data and, when confirmation is enabled, the organization key — can be unsealed:
7//!
8//! ```text
9//! InviteSecret -> SecretProtectedKeyEnvelope -> InviteKey -> SymmetricKeyEnvelope -> InviteDataCEK
10//!                                                ^     |                                   |
11//!                                                |     |               DataEnvelope --------+
12//!                                                |     |                    +-> InviteSecret
13//!                                                |     |                    +-> OrgPubKeyPrint
14//! OrganizationKey -> EncString ------------------+     +-> SymmetricKeyEnvelope -> OrganizationKey
15//! ```
16//!
17//! - `InviteSecret -> SecretProtectedKeyEnvelope -> InviteKey` (`invite_secret_sealed_invite_key`):
18//!   the invite key sealed with the high-entropy invite secret, so an invitee holding only the
19//!   secret from the link recovers the invite key.
20//! - `OrganizationKey -> EncString -> InviteKey` (`organization_key_sealed_invite_key`): the invite
21//!   key sealed with the organization key, so anyone holding the organization key recovers the
22//!   invite key (and thus the invite data).
23//! - `InviteKey -> SymmetricKeyEnvelope -> InviteDataCEK -> DataEnvelope -> InviteSecret +
24//!   OrgPubKeyPrint` (`invite_key_sealed_invite_data_cek` + `sealed_invite_data`): the
25//!   `InviteDataV1` is sealed under its own content-encryption key (CEK), and that CEK is sealed
26//!   with the invite key. The data binds the organization public-key thumbprint and a copy of the
27//!   invite secret (so the invite link can be reconstructed from the invite key).
28//! - `InviteKey -> SymmetricKeyEnvelope -> OrganizationKey` (`invite_key_sealed_organization_key`):
29//!   the organization key sealed with the invite key, so a redeeming invitee can recover the
30//!   organization key. It is present if and exactly if confirmation is enabled for the invite.
31
32use std::str::FromStr;
33
34use bitwarden_crypto::{
35    CoseKeyThumbprint, CoseKeyThumbprintExt, EncString, KeySlotIds, KeyStoreContext,
36    generate_versioned_sealable,
37    safe::{
38        DataEnvelope, DataEnvelopeNamespace, HighEntropySecret, HighEntropySecretSource,
39        KeyEncryptionKey, SealableData, SealableVersionedData, SecretProtectedKeyEnvelope,
40        SecretProtectedKeyEnvelopeNamespace, SymmetricKeyEnvelope, SymmetricKeyEnvelopeNamespace,
41    },
42};
43use bitwarden_encoding::{B64Url, FromStrVisitor};
44use bitwarden_sensitive_value::{Sensitive, SensitiveSlice};
45use rand::Rng;
46use serde::{Deserialize, Serialize};
47use subtle::{Choice, ConstantTimeEq};
48use thiserror::Error;
49use zeroize::Zeroizing;
50
51/// Length, in bytes, of the raw invite secret. 32 bytes provides 256 bits of entropy, which is why
52/// the invite secret is safe to use directly as a [`HighEntropySecret`].
53const INVITE_SECRET_LEN: usize = 32;
54
55/// Errors that can occur when creating or opening an invite.
56#[derive(Debug, Error)]
57pub enum InviteKeyBundleError {
58    /// Decoding the encrypted invite failed
59    #[error("Decoding failed")]
60    DecodingFailed,
61    /// Sealing one of the invite's envelopes failed
62    #[error("Unable to seal invite")]
63    KeySealingFailed,
64    /// Opening one of the invite's envelopes failed
65    #[error("Unable to unseal invite")]
66    KeyUnsealingFailed,
67    /// A required key was not found in the key store context
68    #[error("Missing Key for Id: {0}")]
69    MissingKeyId(String),
70    /// The organization private key could not be unwrapped, or its public-key thumbprint could not
71    /// be derived
72    #[error("Invalid organization private key")]
73    InvalidPrivateKey,
74    /// The organization key cannot be recovered from the invite because confirmation is disabled
75    #[error("Confirmation is not enabled on this invite")]
76    ConfirmationNotEnabled,
77}
78
79#[cfg(feature = "wasm")]
80#[wasm_bindgen::prelude::wasm_bindgen(typescript_custom_section)]
81const TS_INVITE_SECRET: &'static str = r#"
82export type InviteSecret = Tagged<string, "InviteSecret">;
83"#;
84
85/// The invite secret: 32 random, high-entropy bytes carried in the invite link.
86///
87/// CRITICAL: This must never be sent to the server.
88#[derive(Clone)]
89pub struct InviteSecret(Zeroizing<[u8; INVITE_SECRET_LEN]>);
90
91impl InviteSecret {
92    /// Generates a fresh invite secret: 32 random, high-entropy bytes drawn from the SDK CSPRNG.
93    fn make() -> Self {
94        let mut bytes = Zeroizing::new([0u8; INVITE_SECRET_LEN]);
95        bitwarden_random::rng().fill_bytes(bytes.as_mut_slice());
96        InviteSecret(bytes)
97    }
98}
99
100impl ConstantTimeEq for InviteSecret {
101    fn ct_eq(&self, other: &InviteSecret) -> Choice {
102        self.0.as_slice().ct_eq(other.0.as_slice())
103    }
104}
105
106impl PartialEq for InviteSecret {
107    fn eq(&self, other: &Self) -> bool {
108        self.ct_eq(other).into()
109    }
110}
111
112// Manually implemented so the raw invite secret bytes are never printed.
113impl std::fmt::Debug for InviteSecret {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("InviteSecret").finish()
116    }
117}
118
119/// Exposes the invite secret as a [`HighEntropySecret`]. This is sound because the invite secret is
120/// 32 bytes sampled from a CSPRNG.
121impl HighEntropySecretSource for InviteSecret {
122    fn provide_high_entropy_bytes(&self) -> SensitiveSlice<'_> {
123        Sensitive::from(self.0.as_slice())
124    }
125}
126
127impl From<&InviteSecret> for String {
128    fn from(secret: &InviteSecret) -> Self {
129        B64Url::from(secret.0.as_slice()).to_string()
130    }
131}
132
133impl FromStr for InviteSecret {
134    type Err = InviteKeyBundleError;
135
136    fn from_str(s: &str) -> Result<Self, Self::Err> {
137        let data = B64Url::try_from(s).map_err(|_| InviteKeyBundleError::DecodingFailed)?;
138        let bytes: [u8; INVITE_SECRET_LEN] = data
139            .as_bytes()
140            .try_into()
141            .map_err(|_| InviteKeyBundleError::DecodingFailed)?;
142        Ok(InviteSecret(Zeroizing::new(bytes)))
143    }
144}
145
146impl<'de> Deserialize<'de> for InviteSecret {
147    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
148    where
149        D: serde::Deserializer<'de>,
150    {
151        deserializer.deserialize_str(FromStrVisitor::new())
152    }
153}
154
155impl Serialize for InviteSecret {
156    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
157    where
158        S: serde::Serializer,
159    {
160        serializer.serialize_str(&String::from(self))
161    }
162}
163
164/// The plaintext data sealed inside the invite's [`DataEnvelope`] with the invite key.
165///
166/// It binds the organization public-key thumbprint (so account-recovery enrollment can verify the
167/// organization key belongs to the expected identity) and a copy of the invite secret (so a holder
168/// of the invite key can reconstruct the invite link).
169#[derive(Serialize, Deserialize, Debug, PartialEq)]
170struct InviteDataV1 {
171    /// RFC 9679 COSE Key Thumbprint (SHA-256) of the organization public key.
172    public_key_thumbprint: [u8; 32],
173    /// The raw invite secret bytes.
174    invite_secret: [u8; INVITE_SECRET_LEN],
175}
176impl SealableData for InviteDataV1 {}
177
178generate_versioned_sealable!(
179    InviteData,
180    DataEnvelopeNamespace::OrganizationInvite,
181    [
182        InviteDataV1 => "1",
183    ]
184);
185
186#[cfg(feature = "wasm")]
187#[wasm_bindgen::prelude::wasm_bindgen(typescript_custom_section)]
188const TS_INVITE: &'static str = r#"
189export type Invite = Tagged<string, "Invite">;
190"#;
191
192/// Cryptographic invite for an organization, built around an XAES-256-GCM invite key that acts as
193/// the hub tying everything together. See the module-level docs for the overall diagram.
194#[derive(Clone, Serialize, Deserialize)]
195pub struct Invite {
196    /// The `InviteData` (org public-key thumbprint + invite secret) sealed with its own
197    /// content-encryption key (CEK).
198    sealed_invite_data: DataEnvelope,
199    /// The invite-data content-encryption key sealed with the invite key, so a holder of the
200    /// invite key can recover the CEK and open [`Self::sealed_invite_data`].
201    invite_key_sealed_invite_data_cek: SymmetricKeyEnvelope,
202    /// The invite key sealed with the high-entropy invite secret, letting an invitee (who holds
203    /// only the invite secret from the link) recover the invite key.
204    invite_secret_sealed_invite_key: SecretProtectedKeyEnvelope,
205    /// The organization key sealed with the invite key, letting a redeeming invitee recover the
206    /// organization key once they hold the invite key. This is **optional**: its presence is what
207    /// "confirmation" means. When confirmation is enabled the invitee can self-confirm by
208    /// recovering the organization key; when disabled, this field is absent and an admin must
209    /// confirm the invitee out of band.
210    invite_key_sealed_organization_key: Option<SymmetricKeyEnvelope>,
211    /// The invite key sealed with the organization key, letting anyone holding the organization
212    /// key recover the invite key (and thus the invite data).
213    organization_key_sealed_invite_key: EncString,
214}
215
216impl From<&Invite> for String {
217    fn from(invite: &Invite) -> Self {
218        serde_json::to_string(invite).expect("JSON serialization of Invite never fails")
219    }
220}
221
222impl FromStr for Invite {
223    type Err = InviteKeyBundleError;
224
225    fn from_str(s: &str) -> Result<Self, Self::Err> {
226        serde_json::from_str(s).map_err(|_| InviteKeyBundleError::DecodingFailed)
227    }
228}
229
230// Manually implemented to mirror the safe key-envelope primitives: it surfaces the sealed fields
231// without ever printing key material (each field's own `Debug` is key-material-safe).
232impl std::fmt::Debug for Invite {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.debug_struct("Invite")
235            .field("sealed_invite_data", &self.sealed_invite_data)
236            .field(
237                "invite_key_sealed_invite_data_cek",
238                &self.invite_key_sealed_invite_data_cek,
239            )
240            .field(
241                "invite_secret_sealed_invite_key",
242                &self.invite_secret_sealed_invite_key,
243            )
244            .field(
245                "invite_key_sealed_organization_key",
246                &self.invite_key_sealed_organization_key,
247            )
248            .field(
249                "organization_key_sealed_invite_key",
250                &self.organization_key_sealed_invite_key,
251            )
252            .finish()
253    }
254}
255
256impl Invite {
257    /// Recovers the invite key using the organization key
258    pub fn unseal_invite_key_with_organization_key<Ids: KeySlotIds>(
259        &self,
260        organization_key: Ids::Symmetric,
261        ctx: &mut KeyStoreContext<Ids>,
262    ) -> Result<Ids::Symmetric, InviteKeyBundleError> {
263        ctx.unwrap_symmetric_key(organization_key, &self.organization_key_sealed_invite_key)
264            .map_err(|_| InviteKeyBundleError::KeyUnsealingFailed)
265    }
266
267    /// Recovers the invite key from the invite secret
268    pub fn unseal_invite_key_with_invite_secret<Ids: KeySlotIds>(
269        &self,
270        invite_secret: &InviteSecret,
271        ctx: &mut KeyStoreContext<Ids>,
272    ) -> Result<Ids::Symmetric, InviteKeyBundleError> {
273        let secret = HighEntropySecret::from(invite_secret.clone());
274        self.invite_secret_sealed_invite_key
275            .unseal(
276                &secret,
277                SecretProtectedKeyEnvelopeNamespace::OrganizationInvite,
278                ctx,
279            )
280            .map_err(|_| InviteKeyBundleError::KeyUnsealingFailed)
281    }
282
283    /// Unseals the `InviteDataV1` using an invite key
284    fn unseal_invite_data<Ids: KeySlotIds>(
285        &self,
286        invite_key: Ids::Symmetric,
287        ctx: &mut KeyStoreContext<Ids>,
288    ) -> Result<InviteDataV1, InviteKeyBundleError> {
289        // Recover the invite-data CEK by unsealing it with the invite key, then open the data.
290        let cek = self
291            .invite_key_sealed_invite_data_cek
292            .unseal(
293                invite_key,
294                SymmetricKeyEnvelopeNamespace::OrganizationInvite,
295                ctx,
296            )
297            .map_err(|_| InviteKeyBundleError::KeyUnsealingFailed)?;
298        let data: InviteData = self
299            .sealed_invite_data
300            .unseal(cek, ctx)
301            .map_err(|_| InviteKeyBundleError::KeyUnsealingFailed)?;
302        let InviteData::InviteDataV1(data) = data;
303        Ok(data)
304    }
305
306    /// Recovers the [`InviteSecret`] using an invite key
307    pub fn get_invite_secret<Ids: KeySlotIds>(
308        &self,
309        invite_key: Ids::Symmetric,
310        ctx: &mut KeyStoreContext<Ids>,
311    ) -> Result<InviteSecret, InviteKeyBundleError> {
312        let data = self.unseal_invite_data(invite_key, ctx)?;
313        Ok(InviteSecret(Zeroizing::new(data.invite_secret)))
314    }
315
316    /// Recovers the organization public-key thumbprint bound into the invite using an invite key
317    pub fn get_public_key_thumbprint<Ids: KeySlotIds>(
318        &self,
319        invite_key: Ids::Symmetric,
320        ctx: &mut KeyStoreContext<Ids>,
321    ) -> Result<CoseKeyThumbprint, InviteKeyBundleError> {
322        let data = self.unseal_invite_data(invite_key, ctx)?;
323        Ok(CoseKeyThumbprint::from_bytes(data.public_key_thumbprint))
324    }
325
326    /// Whether confirmation is enabled on this invite, i.e. whether the organization key can be
327    /// recovered from the invite key.
328    pub fn supports_confirmation(&self) -> bool {
329        self.invite_key_sealed_organization_key.is_some()
330    }
331
332    /// Unseals the organization key using an invite key, storing it in the key store context and
333    /// returning its id.
334    pub fn unseal_organization_key<Ids: KeySlotIds>(
335        &self,
336        invite_key: Ids::Symmetric,
337        ctx: &mut KeyStoreContext<Ids>,
338    ) -> Result<Ids::Symmetric, InviteKeyBundleError> {
339        self.invite_key_sealed_organization_key
340            .as_ref()
341            .ok_or(InviteKeyBundleError::ConfirmationNotEnabled)?
342            .unseal(
343                invite_key,
344                SymmetricKeyEnvelopeNamespace::OrganizationInvite,
345                ctx,
346            )
347            .map_err(|_| InviteKeyBundleError::KeyUnsealingFailed)
348    }
349
350    /// Enables confirmation
351    pub fn enable_confirmation<Ids: KeySlotIds>(
352        &mut self,
353        organization_key: Ids::Symmetric,
354        ctx: &mut KeyStoreContext<Ids>,
355    ) -> Result<(), InviteKeyBundleError> {
356        let invite_key = self.unseal_invite_key_with_organization_key(organization_key, ctx)?;
357        let envelope = SymmetricKeyEnvelope::seal(
358            organization_key,
359            invite_key,
360            SymmetricKeyEnvelopeNamespace::OrganizationInvite,
361            ctx,
362        )
363        .map_err(|_| InviteKeyBundleError::KeySealingFailed)?;
364        self.invite_key_sealed_organization_key = Some(envelope);
365        Ok(())
366    }
367
368    /// Disables confirmation
369    pub fn disable_confirmation(&mut self) {
370        self.invite_key_sealed_organization_key = None;
371    }
372
373    /// Generates a brand new invite around a new invite key. The invite is sealed for the
374    /// provided organization key and bound to the organization's public-key thumbprint (see
375    /// [`Invite`]).
376    ///
377    /// `wrapped_organization_private_key` is the organization's private key wrapped with
378    /// `organization_key`; the public-key thumbprint bound into the invite is derived from it.
379    ///
380    /// Returns the raw [`InviteSecret`] (which MUST NOT be sent to the server) together with the
381    /// server-safe [`Invite`].
382    pub fn make_for_private_key<Ids: KeySlotIds>(
383        organization_key: Ids::Symmetric,
384        wrapped_organization_private_key: &EncString,
385        ctx: &mut KeyStoreContext<Ids>,
386    ) -> Result<(InviteSecret, Invite), InviteKeyBundleError> {
387        // Derive the organization public-key thumbprint from the wrapped private key.
388        let private_key_id = ctx
389            .unwrap_private_key(organization_key, wrapped_organization_private_key)
390            .map_err(|_| InviteKeyBundleError::InvalidPrivateKey)?;
391        let thumbprint = ctx
392            .get_public_key(private_key_id)
393            .map_err(|_| InviteKeyBundleError::InvalidPrivateKey)?
394            .thumbprint()
395            .map_err(|_| InviteKeyBundleError::InvalidPrivateKey)?;
396
397        let invite_secret = InviteSecret::make();
398        let invite_key = KeyEncryptionKey::make(ctx);
399
400        // Seal the invite data (thumbprint + a copy of the invite secret) under a fresh
401        // content-encryption key (CEK), then seal that CEK with the invite key so a holder of the
402        // invite key can open the data.
403        let invite_data: InviteData = InviteDataV1 {
404            public_key_thumbprint: *thumbprint.as_bytes(),
405            invite_secret: *invite_secret.0,
406        }
407        .into();
408        let (sealed_invite_data, invite_data_cek) = DataEnvelope::seal(invite_data, ctx)
409            .map_err(|_| InviteKeyBundleError::KeySealingFailed)?;
410        let invite_key_sealed_invite_data_cek = SymmetricKeyEnvelope::seal(
411            invite_data_cek,
412            invite_key,
413            SymmetricKeyEnvelopeNamespace::OrganizationInvite,
414            ctx,
415        )
416        .map_err(|_| InviteKeyBundleError::KeySealingFailed)?;
417
418        // Seal the invite key with the invite secret (invitee -> invite key direction).
419        let secret = HighEntropySecret::from(invite_secret.clone());
420        let invite_secret_sealed_invite_key = SecretProtectedKeyEnvelope::seal(
421            invite_key,
422            &secret,
423            SecretProtectedKeyEnvelopeNamespace::OrganizationInvite,
424            ctx,
425        )
426        .map_err(|_| InviteKeyBundleError::KeySealingFailed)?;
427
428        // Seal the organization key with the invite key (invite key -> organization key direction).
429        // New invites are created with confirmation enabled; callers can disable it afterwards via
430        // `Invite::disable_confirmation`.
431        let invite_key_sealed_organization_key = Some(
432            SymmetricKeyEnvelope::seal(
433                organization_key,
434                invite_key,
435                SymmetricKeyEnvelopeNamespace::OrganizationInvite,
436                ctx,
437            )
438            .map_err(|_| InviteKeyBundleError::KeySealingFailed)?,
439        );
440
441        // Seal the invite key with the organization key (organization key -> invite key direction).
442        let organization_key_sealed_invite_key = ctx
443            .wrap_symmetric_key(organization_key, invite_key)
444            .map_err(|_| InviteKeyBundleError::KeySealingFailed)?;
445
446        Ok((
447            invite_secret,
448            Invite {
449                sealed_invite_data,
450                invite_key_sealed_invite_data_cek,
451                invite_secret_sealed_invite_key,
452                invite_key_sealed_organization_key,
453                organization_key_sealed_invite_key,
454            },
455        ))
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use bitwarden_crypto::{
462        CoseKeyThumbprintExt, EncString, KeyStore, KeyStoreContext, PublicKeyEncryptionAlgorithm,
463        SymmetricCryptoKey, key_slot_ids,
464    };
465    use bitwarden_encoding::{B64, B64Url};
466
467    use crate::invite::{Invite, InviteKeyBundleError, InviteSecret};
468
469    // Test vectors captured from `generate_test_vectors`. They freeze a real organization key, the
470    // organization private key wrapped with it, and an invite, so backward compatibility (old data
471    // must remain decryptable) is verified by `test_invite_test_vector`.
472    const TEST_VECTOR_ORG_KEY: &str =
473        "KGP9Nc2/91w+42Z9VzY0m7h18avuZcq4ICM8Rhdc3BD92LbWS2TQkVBzavvUM684WKXiC22NJi2EwaiDW4YTAA==";
474    const TEST_VECTOR_WRAPPED_PRIVATE_KEY: &str = "2.bi9TWF/zrujUg1y+v8ECtQ==|kEMkuRt42j65YZnPEc4bLOT0/WDZwWSNJNGlUMdr/LRF3qi/vCnZ7eT0+7MruTccmyoAjKmsXdoBcufrOdPBUguFQn1LQMGqHqCyyB3SIijOlLyOOmxWYqoLUjihy8o8URGWjrAGWZnkeYWHTlFZP09Fag2xCwiQ/qS32Q+qTGGHDs0FiwjplcPkW9knlmgCXbuyPqDnEYoa0Qs/CC1hUCzFFrWs7QkE+5eLaNHxuBPpsrY6y795kEu9ve38F3piY9b6lQpc/iPGv8Zfh1isI7Mpy1zMwntXGSHjUOy17nPxCqkgYufuNGnwGNwsGjkLAl7e7bD39SYfEpTDaRUgmTl8UrZDx274e6Um1LLvokf3HiL1tboJ9/TW8IiMuAdrb3PLTH6Sep8lqZ3WhNADfMMJle9kCojHp6XSB14in1JqP0636exYeJu+FhUC1TfrthuQN2QDQ8LAvgZR7YvzkTJX3Sc5jP7m/yCmCbHhIqIAaGqJsRwAee0EMsKcALz3/akVoyjHU2LHD3dzQnyMyszyRNYViBNYAM9qBN2DqwWRDOtM171xNVJcTFsAh4mBSLiLOlDsXqLqHVKu2VJNE1XhTQ5Szubqefa/Or7nfxXcxDvivqZ7d5NfDFEskUMqh5yq1KoLK0oK5c+KvY/COIZr/kct+qtfZsXo3w5xJnPOqrAKGm+9CF4OINpLM8Z3csdZf9l5XjlmO1kIuBbquQZ0EZCHzD/GEfXRGB8BEkugdTfpnTDtmmuAJXkIY6t6e6pRUU9u4/sl+U7Iuh22fOA59SuQOElr5Lxre+hQBrRfJS3tSMEtMjYhVmltrngH+SLRxMxH/evbe2uvNaaxlFJe6EK1vqchyTX6nM8Z+2Yjb5pOAzrKQYqwwmVys9IHfjXhybqv10gFpDXBE/eq8u9xs9LbQQ03EbveQUtqdh/ms8SxLOQA9Sm9JwHEL0Zni+8NdAa5orDYzOi53bQLgrs+uUldgB/KOW2goTnKGm4YTbMAXEbum8pST8EXB9jNDXyofyN8IQUoLRvVkEgzbSPBS1sYpkkKdLZy3ojOCKnMnHXIVtzJojFckiutbj6d927X5w51E/RDMoAdylGRVKnmqLKysFRqL+pZK8Cyo+ECr7notG8kr7pVnofzigjKZS9qkRmqEa9bju1GgI20g4cxro8/0O0XnU1o0Mx46qH3niORY59i5bdMwaDS6H2c6+rmf9bIFwwgyAZvHlVdcGNoBGPR3ZXHThwI1OmWSslLWVW0IaS4utB1jL4zvHPCqh/ButA8HeRmU/NYSfaqb9YXyzn+C7ED15MWXkYmZzeE38HHxhqs12+oj+WFcg4d5/e2UcccuVi36SWhA0xWk8Kk2D6e1Pz1lmaw20vpb0eUq4AS5ZnMmWTEiKORFeGNTIROq9vuPuitLrREedu9PGjf5aeKcNqlq2nr7fOaxyi2ocKs7pLqVBUH1G7zHpCo3Rt1+o18guXFHT56vQFfkzoUNXiS6TRM4Mkl3s0TyGgBhxZkNJleTI6y4xhfH1iathBnLcfelLbxZhDB1wh3RXowS32jpM/J4pSUuNEmSLSqRQtRJY41BG00nYY02qEbakkgk6pS6a0/CWphyLfHAzUWbabOhqR1iPN9/ZiecjI=|eoklmBw+LYy2NNwjLOuA4+szKH5SzLGPlrhIJ/vfmW4=";
475    const TEST_VECTOR_INVITE: &str = r#"{"sealed_invite_data":"g1hHpQEDA3gjYXBwbGljYXRpb24veC5iaXR3YXJkZW4uY2Jvci1wYWRkZWQEUPw60HtcnAwO6kRKd7MnQz86AAE4gQI6AAE4gAKhBUz+fzObmLDLKRDeBJJYxX9qAxhXIe1Ri1CJw3ojv7WUEwBpVWeMEGTK7HHbp9WnTDjK849psMx6EOpQ7B/BYGiO28Zn0tjtQ85zwShii4FRK39mtJFE8XxV46hxW9+LlH6EPt4yfzHVkTNZ3vgOiSXcs6zyKXIhQaz6yh8eyA653m4DJEHV00JjdQ/jSfTEfyDf6LiflGntDZ7gSYlQMRxuS71yVP9Mgm79yFY4aIw703G/HUjvIEt4XlWCLzvaLDmegpDz9z4eQCVk8v8JM1fS7BsA","invite_key_sealed_invite_data_cek":"g1g+pgE6AAEReQMYZQRQ7raDdes1HnAgiuiZWXyS9ToAARVcUPw60HtcnAwO6kRKd7MnQz86AAE4gQM6AAE4gAKhBVgYvxSL4XPZLwEXHPnEWweqeeKjyH9r85/ZWE3rsZy1MLmzkFOCJa8a0o20b1P3IH6c5NYtT3v1a50+X0GmgDKiMf/omjhwRRQ7ua6wK0O2JBlhM4JIE8jmtsyMd46mFDbURK1kGRvl7Q==","invite_secret_sealed_invite_key":"hFgopQEDAxhlOgABFVxQ7raDdes1HnAgiuiZWXyS9ToAATiBBjoAATiAAaEFTJvCToVTNSe1uWbD5FhUf061kAAj1sMGWjJ8IWPV2e2xk5Z+ownZ1RqUXG2jv9h2vnsEFZ2yglvKaSCGTsOuvOXv0ESwQk6eUtFAaZjxV/Rajyuu6YgEa712Tfb9Jz13QLGngYNAogEpM1gg1MsuVokUsK8WkqggHRdJ4jvzFsbN/bP4g0l+j6f97qb2","invite_key_sealed_organization_key":"g1hKpQE6AAEReQN4ImFwcGxpY2F0aW9uL3guYml0d2FyZGVuLmxlZ2FjeS1rZXkEUO62g3XrNR5wIIromVl8kvU6AAE4gQM6AAE4gAKhBVgYoth3hg+yUTLXF4ksaeT42IWGKuTv27B4WFAKV8Z7uKGZNHOONGgZQLbQMozgYX9tseuet413M314W0sV3cBKZIRSEZfj3NvHU8tE/6b2oGxPQIPKP3Tyhl84zhI4uG+Mo5WKkvtdPonD0A==","organization_key_sealed_invite_key":"2.XjZXnAwXK/cXCmHNgCPQzw==|WEGa37JPdNWVnMPWlfinyvZdXpMW8kpTBypXPWf/abbG4/+6vp/WJQmVlkVEflEkuZwjKWSkMWJPcACGBoabeBHgzqpkSYf3kyQb7VhePHQ=|kZLDCLGm1nCJhlVuNahFiZOecy5tKG2fXGCNZZzbDjk="}"#;
476    const TEST_VECTOR_INVITE_SECRET: &str = "Ttas45_CvZi1yoFJ3bMCHx0DAAGGxDi-1BhHCutwDjI";
477
478    /// Loads the const `TEST_VECTOR_ORG_KEY` into the `Organization` slot and returns the parsed
479    /// const wrapped organization private key. Sharing fixed vectors keeps tests deterministic and
480    /// avoids generating a fresh RSA key on every run.
481    fn load_test_vectors(ctx: &mut KeyStoreContext<'_, TestIds>) -> EncString {
482        let org_key =
483            SymmetricCryptoKey::try_from(B64::try_from(TEST_VECTOR_ORG_KEY).unwrap()).unwrap();
484        let local_org_key_id = ctx.add_local_symmetric_key(org_key);
485        ctx.persist_symmetric_key(local_org_key_id, TestSymmKey::Organization)
486            .unwrap();
487        TEST_VECTOR_WRAPPED_PRIVATE_KEY.parse().unwrap()
488    }
489
490    #[test]
491    fn test_basic_invitation_bundle() {
492        let key_store = KeyStore::<TestIds>::default();
493        let mut ctx = key_store.context_mut();
494        let wrapped_private_key = load_test_vectors(&mut ctx);
495
496        let (secret1, _) =
497            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
498                .unwrap();
499        let (secret2, _) =
500            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
501                .unwrap();
502
503        assert_ne!(secret1, secret2);
504    }
505
506    #[test]
507    fn test_admin_recovers_invite_secret() {
508        let key_store = KeyStore::<TestIds>::default();
509        let mut ctx = key_store.context_mut();
510        let wrapped_private_key = load_test_vectors(&mut ctx);
511
512        let (invite_secret, invite) =
513            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
514                .unwrap();
515
516        let invite_key = invite
517            .unseal_invite_key_with_organization_key(TestSymmKey::Organization, &mut ctx)
518            .unwrap();
519        let recovered = invite.get_invite_secret(invite_key, &mut ctx).unwrap();
520
521        assert_eq!(invite_secret, recovered);
522    }
523
524    #[test]
525    fn test_admin_recovers_thumbprint() {
526        let key_store = KeyStore::<TestIds>::default();
527        let mut ctx = key_store.context_mut();
528        let wrapped_private_key = load_test_vectors(&mut ctx);
529
530        // The expected thumbprint is derived from the same wrapped private key that
531        // `make_for_private_key` binds into the invite.
532        let private_key_id = ctx
533            .unwrap_private_key(TestSymmKey::Organization, &wrapped_private_key)
534            .unwrap();
535        let expected = ctx
536            .get_public_key(private_key_id)
537            .unwrap()
538            .thumbprint()
539            .unwrap();
540
541        let (_, invite) =
542            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
543                .unwrap();
544
545        let invite_key = invite
546            .unseal_invite_key_with_organization_key(TestSymmKey::Organization, &mut ctx)
547            .unwrap();
548        let recovered = invite
549            .get_public_key_thumbprint(invite_key, &mut ctx)
550            .unwrap();
551
552        assert_eq!(recovered, expected);
553    }
554
555    #[test]
556    fn test_invitee_recovers_organization_key() {
557        let key_store = KeyStore::<TestIds>::default();
558        let mut ctx = key_store.context_mut();
559        let wrapped_private_key = load_test_vectors(&mut ctx);
560
561        let (invite_secret, invite) =
562            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
563                .unwrap();
564
565        // Using only the raw invite secret, an invitee can recover the invite key and then the
566        // organization key.
567        let invite_key = invite
568            .unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)
569            .unwrap();
570        let recovered_org_key_id = invite
571            .unseal_organization_key(invite_key, &mut ctx)
572            .unwrap();
573
574        ctx.assert_symmetric_keys_equal(recovered_org_key_id, TestSymmKey::Organization);
575    }
576
577    #[test]
578    fn test_confirmation_toggle() {
579        let key_store = KeyStore::<TestIds>::default();
580        let mut ctx = key_store.context_mut();
581        let wrapped_private_key = load_test_vectors(&mut ctx);
582
583        let (invite_secret, mut invite) =
584            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
585                .unwrap();
586
587        // New invites are created with confirmation enabled.
588        assert!(invite.supports_confirmation());
589
590        // Disabling confirmation removes the org-key envelope, so an invitee can no longer recover
591        // the organization key.
592        invite.disable_confirmation();
593        assert!(!invite.supports_confirmation());
594        let invite_key = invite
595            .unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)
596            .unwrap();
597        assert!(matches!(
598            invite.unseal_organization_key(invite_key, &mut ctx),
599            Err(InviteKeyBundleError::ConfirmationNotEnabled)
600        ));
601
602        // Re-enabling confirmation restores the invitee's ability to recover the organization key.
603        invite
604            .enable_confirmation(TestSymmKey::Organization, &mut ctx)
605            .unwrap();
606        assert!(invite.supports_confirmation());
607        let invite_key = invite
608            .unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)
609            .unwrap();
610        let recovered_org_key_id = invite
611            .unseal_organization_key(invite_key, &mut ctx)
612            .unwrap();
613        ctx.assert_symmetric_keys_equal(recovered_org_key_id, TestSymmKey::Organization);
614    }
615
616    #[test]
617    fn test_invite_string_round_trip() {
618        let key_store = KeyStore::<TestIds>::default();
619        let mut ctx = key_store.context_mut();
620        let wrapped_private_key = load_test_vectors(&mut ctx);
621
622        let (invite_secret, invite) =
623            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
624                .unwrap();
625
626        let encoded = String::from(&invite);
627        let decoded: Invite = encoded.parse().unwrap();
628        assert_eq!(String::from(&decoded), encoded);
629
630        // The decoded invite still recovers the invite secret.
631        let invite_key = decoded
632            .unseal_invite_key_with_organization_key(TestSymmKey::Organization, &mut ctx)
633            .unwrap();
634        let recovered = decoded.get_invite_secret(invite_key, &mut ctx).unwrap();
635        assert_eq!(invite_secret, recovered);
636
637        // The custom serde impls delegate to the string round-trip.
638        let json = serde_json::to_string(&invite).unwrap();
639        let from_json: Invite = serde_json::from_str(&json).unwrap();
640        assert_eq!(String::from(&from_json), encoded);
641    }
642
643    #[test]
644    fn test_wrong_invite_secret_fails() {
645        let key_store = KeyStore::<TestIds>::default();
646        let mut ctx = key_store.context_mut();
647        let wrapped_private_key = load_test_vectors(&mut ctx);
648
649        let (_, invite) =
650            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
651                .unwrap();
652
653        // A different invite secret cannot unseal the invite key.
654        let wrong_secret = InviteSecret(zeroize::Zeroizing::new([7u8; 32]));
655        assert!(matches!(
656            invite.unseal_invite_key_with_invite_secret(&wrong_secret, &mut ctx),
657            Err(InviteKeyBundleError::KeyUnsealingFailed)
658        ));
659    }
660
661    #[test]
662    fn test_wrong_organization_key_fails() {
663        let key_store = KeyStore::<TestIds>::default();
664        let mut ctx = key_store.context_mut();
665        let wrapped_private_key = load_test_vectors(&mut ctx);
666
667        let (_, invite) =
668            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
669                .unwrap();
670
671        // A different organization key cannot unwrap the invite key.
672        let wrong_org_key = ctx.generate_symmetric_key();
673        assert!(matches!(
674            invite.unseal_invite_key_with_organization_key(wrong_org_key, &mut ctx),
675            Err(InviteKeyBundleError::KeyUnsealingFailed)
676        ));
677    }
678
679    #[test]
680    fn test_invitee_recovers_thumbprint() {
681        let key_store = KeyStore::<TestIds>::default();
682        let mut ctx = key_store.context_mut();
683        let wrapped_private_key = load_test_vectors(&mut ctx);
684
685        let private_key_id = ctx
686            .unwrap_private_key(TestSymmKey::Organization, &wrapped_private_key)
687            .unwrap();
688        let expected = ctx
689            .get_public_key(private_key_id)
690            .unwrap()
691            .thumbprint()
692            .unwrap();
693
694        let (invite_secret, invite) =
695            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
696                .unwrap();
697
698        // The invitee reaches the invite key via the invite secret and reads the same bound
699        // thumbprint the admin would.
700        let invite_key = invite
701            .unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)
702            .unwrap();
703        let recovered = invite
704            .get_public_key_thumbprint(invite_key, &mut ctx)
705            .unwrap();
706
707        assert_eq!(recovered, expected);
708    }
709
710    #[test]
711    fn test_malformed_invite_string_fails() {
712        // Not JSON at all.
713        assert!(matches!(
714            "not valid json !!!".parse::<Invite>(),
715            Err(InviteKeyBundleError::DecodingFailed)
716        ));
717
718        // Valid JSON, but missing the required invite fields.
719        assert!(matches!(
720            "{}".parse::<Invite>(),
721            Err(InviteKeyBundleError::DecodingFailed)
722        ));
723    }
724
725    #[test]
726    fn test_invalid_wrapped_private_key_fails() {
727        let key_store = KeyStore::<TestIds>::default();
728        let mut ctx = key_store.context_mut();
729        let wrapped_private_key = load_test_vectors(&mut ctx);
730
731        // The wrapped private key can only be unwrapped with the organization key it was wrapped
732        // with; a different key makes `make_for_private_key` fail before building the invite.
733        let wrong_org_key = ctx.generate_symmetric_key();
734        assert!(matches!(
735            Invite::make_for_private_key(wrong_org_key, &wrapped_private_key, &mut ctx),
736            Err(InviteKeyBundleError::InvalidPrivateKey)
737        ));
738    }
739
740    #[test]
741    fn test_invite_secret_into_base64_url() {
742        let data: [u8; 32] = *b"+/=Hello, World!AAAAAAAAAAAAAAAA";
743        let secret = InviteSecret(zeroize::Zeroizing::new(data));
744
745        let encoded = String::from(&secret);
746
747        assert_eq!(encoded, "Ky89SGVsbG8sIFdvcmxkIUFBQUFBQUFBQUFBQUFBQUE");
748        assert!(!encoded.contains('+'));
749        assert!(!encoded.contains('/'));
750        assert!(!encoded.contains('='));
751
752        let decoded = B64Url::try_from(encoded.as_str()).unwrap();
753        assert_eq!(decoded.as_bytes(), data);
754
755        // Round-trips back to the same invite secret.
756        let reparsed: InviteSecret = encoded.parse().unwrap();
757        assert_eq!(reparsed, secret);
758    }
759
760    #[test]
761    #[ignore = "Manual test to generate test vectors"]
762    fn generate_test_vectors() {
763        let key_store = KeyStore::<TestIds>::default();
764        let mut ctx = key_store.context_mut();
765
766        let org_key =
767            SymmetricCryptoKey::try_from(B64::try_from(TEST_VECTOR_ORG_KEY).unwrap()).unwrap();
768        let org_key_id = ctx.add_local_symmetric_key(org_key);
769
770        // Make and wrap a fresh organization private key so it can be recorded as a fixed vector.
771        let private_key_id = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
772        let wrapped_private_key = ctx.wrap_private_key(org_key_id, private_key_id).unwrap();
773
774        let (invite_secret, invite) =
775            Invite::make_for_private_key(org_key_id, &wrapped_private_key, &mut ctx).unwrap();
776
777        println!(
778            "const TEST_VECTOR_WRAPPED_PRIVATE_KEY: &str = \"{}\";",
779            wrapped_private_key.to_string()
780        );
781        println!(
782            "const TEST_VECTOR_INVITE: &str = r#\"{}\"#;",
783            String::from(&invite)
784        );
785        println!(
786            "const TEST_VECTOR_INVITE_SECRET: &str = \"{}\";",
787            String::from(&invite_secret)
788        );
789    }
790
791    #[test]
792    fn test_invite_test_vector() {
793        let key_store = KeyStore::<TestIds>::default();
794        let mut ctx = key_store.context_mut();
795
796        let org_key =
797            SymmetricCryptoKey::try_from(B64::try_from(TEST_VECTOR_ORG_KEY).unwrap()).unwrap();
798        let org_key_id = ctx.add_local_symmetric_key(org_key);
799
800        let invite: Invite = TEST_VECTOR_INVITE.parse().unwrap();
801        let invite_key = invite
802            .unseal_invite_key_with_organization_key(org_key_id, &mut ctx)
803            .unwrap();
804        let recovered = invite.get_invite_secret(invite_key, &mut ctx).unwrap();
805
806        assert_eq!(String::from(&recovered), TEST_VECTOR_INVITE_SECRET);
807    }
808
809    #[test]
810    #[ignore = "Manual test to verify debug format"]
811    fn test_debug() {
812        let key_store = KeyStore::<TestIds>::default();
813        let mut ctx = key_store.context_mut();
814        let wrapped_private_key = load_test_vectors(&mut ctx);
815
816        let (invite_secret, invite) =
817            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
818                .unwrap();
819        println!("{invite_secret:?}");
820        println!("{invite:?}");
821    }
822
823    key_slot_ids! {
824        #[symmetric]
825        pub enum TestSymmKey {
826            Organization,
827            #[local]
828            Local(LocalId),
829        }
830
831        #[private]
832        pub enum TestPrivateKey {
833            A(u8),
834            B,
835            #[local]
836            C(LocalId),
837        }
838
839        #[signing]
840        pub enum TestSigningKey {
841            A(u8),
842            B,
843            #[local]
844            C(LocalId),
845        }
846
847       pub TestIds => TestSymmKey, TestPrivateKey, TestSigningKey;
848    }
849}