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)]
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
216/// Wire format for [`Invite`]. This is what's serialized by serde.
217///
218/// [`Invite`] itself serializes as its [`String`] form, so this mirror struct carries the actual
219/// JSON field layout. Keeping the two apart means an invite is a single opaque string wherever it
220/// is embedded — on the wire, in storage, and across the WASM boundary, where it is typed as
221/// `Tagged<string, "Invite">`.
222#[derive(Serialize, Deserialize)]
223struct SerializedInvite {
224    sealed_invite_data: DataEnvelope,
225    invite_key_sealed_invite_data_cek: SymmetricKeyEnvelope,
226    invite_secret_sealed_invite_key: SecretProtectedKeyEnvelope,
227    invite_key_sealed_organization_key: Option<SymmetricKeyEnvelope>,
228    organization_key_sealed_invite_key: EncString,
229}
230
231impl From<&Invite> for SerializedInvite {
232    fn from(invite: &Invite) -> Self {
233        SerializedInvite {
234            sealed_invite_data: invite.sealed_invite_data.clone(),
235            invite_key_sealed_invite_data_cek: invite.invite_key_sealed_invite_data_cek.clone(),
236            invite_secret_sealed_invite_key: invite.invite_secret_sealed_invite_key.clone(),
237            invite_key_sealed_organization_key: invite.invite_key_sealed_organization_key.clone(),
238            organization_key_sealed_invite_key: invite.organization_key_sealed_invite_key.clone(),
239        }
240    }
241}
242
243impl From<&Invite> for String {
244    fn from(invite: &Invite) -> Self {
245        serde_json::to_string(&SerializedInvite::from(invite))
246            .expect("JSON serialization of Invite never fails")
247    }
248}
249
250impl FromStr for Invite {
251    type Err = InviteKeyBundleError;
252
253    fn from_str(s: &str) -> Result<Self, Self::Err> {
254        let data: SerializedInvite =
255            serde_json::from_str(s).map_err(|_| InviteKeyBundleError::DecodingFailed)?;
256        Ok(Invite {
257            sealed_invite_data: data.sealed_invite_data,
258            invite_key_sealed_invite_data_cek: data.invite_key_sealed_invite_data_cek,
259            invite_secret_sealed_invite_key: data.invite_secret_sealed_invite_key,
260            invite_key_sealed_organization_key: data.invite_key_sealed_organization_key,
261            organization_key_sealed_invite_key: data.organization_key_sealed_invite_key,
262        })
263    }
264}
265
266impl<'de> Deserialize<'de> for Invite {
267    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
268    where
269        D: serde::Deserializer<'de>,
270    {
271        deserializer.deserialize_str(FromStrVisitor::new())
272    }
273}
274
275impl Serialize for Invite {
276    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
277    where
278        S: serde::Serializer,
279    {
280        serializer.serialize_str(&String::from(self))
281    }
282}
283
284// Manually implemented to mirror the safe key-envelope primitives: it surfaces the sealed fields
285// without ever printing key material (each field's own `Debug` is key-material-safe).
286impl std::fmt::Debug for Invite {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        f.debug_struct("Invite")
289            .field("sealed_invite_data", &self.sealed_invite_data)
290            .field(
291                "invite_key_sealed_invite_data_cek",
292                &self.invite_key_sealed_invite_data_cek,
293            )
294            .field(
295                "invite_secret_sealed_invite_key",
296                &self.invite_secret_sealed_invite_key,
297            )
298            .field(
299                "invite_key_sealed_organization_key",
300                &self.invite_key_sealed_organization_key,
301            )
302            .field(
303                "organization_key_sealed_invite_key",
304                &self.organization_key_sealed_invite_key,
305            )
306            .finish()
307    }
308}
309
310impl Invite {
311    /// Recovers the invite key using the organization key
312    pub fn unseal_invite_key_with_organization_key<Ids: KeySlotIds>(
313        &self,
314        organization_key: Ids::Symmetric,
315        ctx: &mut KeyStoreContext<Ids>,
316    ) -> Result<Ids::Symmetric, InviteKeyBundleError> {
317        ctx.unwrap_symmetric_key(organization_key, &self.organization_key_sealed_invite_key)
318            .map_err(|_| InviteKeyBundleError::KeyUnsealingFailed)
319    }
320
321    /// Recovers the invite key from the invite secret
322    pub fn unseal_invite_key_with_invite_secret<Ids: KeySlotIds>(
323        &self,
324        invite_secret: &InviteSecret,
325        ctx: &mut KeyStoreContext<Ids>,
326    ) -> Result<Ids::Symmetric, InviteKeyBundleError> {
327        let secret = HighEntropySecret::from(invite_secret.clone());
328        self.invite_secret_sealed_invite_key
329            .unseal(
330                &secret,
331                SecretProtectedKeyEnvelopeNamespace::OrganizationInvite,
332                ctx,
333            )
334            .map_err(|_| InviteKeyBundleError::KeyUnsealingFailed)
335    }
336
337    /// Unseals the `InviteDataV1` using an invite key
338    fn unseal_invite_data<Ids: KeySlotIds>(
339        &self,
340        invite_key: Ids::Symmetric,
341        ctx: &mut KeyStoreContext<Ids>,
342    ) -> Result<InviteDataV1, InviteKeyBundleError> {
343        // Recover the invite-data CEK by unsealing it with the invite key, then open the data.
344        let cek = self
345            .invite_key_sealed_invite_data_cek
346            .unseal(
347                invite_key,
348                SymmetricKeyEnvelopeNamespace::OrganizationInvite,
349                ctx,
350            )
351            .map_err(|_| InviteKeyBundleError::KeyUnsealingFailed)?;
352        let data: InviteData = self
353            .sealed_invite_data
354            .unseal(cek, ctx)
355            .map_err(|_| InviteKeyBundleError::KeyUnsealingFailed)?;
356        let InviteData::InviteDataV1(data) = data;
357        Ok(data)
358    }
359
360    /// Recovers the [`InviteSecret`] using an invite key
361    pub fn get_invite_secret<Ids: KeySlotIds>(
362        &self,
363        invite_key: Ids::Symmetric,
364        ctx: &mut KeyStoreContext<Ids>,
365    ) -> Result<InviteSecret, InviteKeyBundleError> {
366        let data = self.unseal_invite_data(invite_key, ctx)?;
367        Ok(InviteSecret(Zeroizing::new(data.invite_secret)))
368    }
369
370    /// Recovers the organization public-key thumbprint bound into the invite using an invite key
371    pub fn get_public_key_thumbprint<Ids: KeySlotIds>(
372        &self,
373        invite_key: Ids::Symmetric,
374        ctx: &mut KeyStoreContext<Ids>,
375    ) -> Result<CoseKeyThumbprint, InviteKeyBundleError> {
376        let data = self.unseal_invite_data(invite_key, ctx)?;
377        Ok(CoseKeyThumbprint::from_bytes(data.public_key_thumbprint))
378    }
379
380    /// Whether confirmation is enabled on this invite, i.e. whether the organization key can be
381    /// recovered from the invite key.
382    pub fn supports_confirmation(&self) -> bool {
383        self.invite_key_sealed_organization_key.is_some()
384    }
385
386    /// Unseals the organization key using an invite key, storing it in the key store context and
387    /// returning its id.
388    pub fn unseal_organization_key<Ids: KeySlotIds>(
389        &self,
390        invite_key: Ids::Symmetric,
391        ctx: &mut KeyStoreContext<Ids>,
392    ) -> Result<Ids::Symmetric, InviteKeyBundleError> {
393        self.invite_key_sealed_organization_key
394            .as_ref()
395            .ok_or(InviteKeyBundleError::ConfirmationNotEnabled)?
396            .unseal(
397                invite_key,
398                SymmetricKeyEnvelopeNamespace::OrganizationInvite,
399                ctx,
400            )
401            .map_err(|_| InviteKeyBundleError::KeyUnsealingFailed)
402    }
403
404    /// Enables confirmation
405    pub fn enable_confirmation<Ids: KeySlotIds>(
406        &mut self,
407        organization_key: Ids::Symmetric,
408        ctx: &mut KeyStoreContext<Ids>,
409    ) -> Result<(), InviteKeyBundleError> {
410        let invite_key = self.unseal_invite_key_with_organization_key(organization_key, ctx)?;
411        let envelope = SymmetricKeyEnvelope::seal(
412            organization_key,
413            invite_key,
414            SymmetricKeyEnvelopeNamespace::OrganizationInvite,
415            ctx,
416        )
417        .map_err(|_| InviteKeyBundleError::KeySealingFailed)?;
418        self.invite_key_sealed_organization_key = Some(envelope);
419        Ok(())
420    }
421
422    /// Disables confirmation
423    pub fn disable_confirmation(&mut self) {
424        self.invite_key_sealed_organization_key = None;
425    }
426
427    /// Generates a brand new invite around a new invite key. The invite is sealed for the
428    /// provided organization key and bound to the organization's public-key thumbprint (see
429    /// [`Invite`]).
430    ///
431    /// `wrapped_organization_private_key` is the organization's private key wrapped with
432    /// `organization_key`; the public-key thumbprint bound into the invite is derived from it.
433    ///
434    /// Returns the raw [`InviteSecret`] (which MUST NOT be sent to the server) together with the
435    /// server-safe [`Invite`].
436    pub fn make_for_private_key<Ids: KeySlotIds>(
437        organization_key: Ids::Symmetric,
438        wrapped_organization_private_key: &EncString,
439        ctx: &mut KeyStoreContext<Ids>,
440    ) -> Result<(InviteSecret, Invite), InviteKeyBundleError> {
441        // Derive the organization public-key thumbprint from the wrapped private key.
442        let private_key_id = ctx
443            .unwrap_private_key(organization_key, wrapped_organization_private_key)
444            .map_err(|_| InviteKeyBundleError::InvalidPrivateKey)?;
445        let thumbprint = ctx
446            .get_public_key(private_key_id)
447            .map_err(|_| InviteKeyBundleError::InvalidPrivateKey)?
448            .thumbprint()
449            .map_err(|_| InviteKeyBundleError::InvalidPrivateKey)?;
450
451        let invite_secret = InviteSecret::make();
452        let invite_key = KeyEncryptionKey::make(ctx);
453
454        // Seal the invite data (thumbprint + a copy of the invite secret) under a fresh
455        // content-encryption key (CEK), then seal that CEK with the invite key so a holder of the
456        // invite key can open the data.
457        let invite_data: InviteData = InviteDataV1 {
458            public_key_thumbprint: *thumbprint.as_bytes(),
459            invite_secret: *invite_secret.0,
460        }
461        .into();
462        let (sealed_invite_data, invite_data_cek) = DataEnvelope::seal(invite_data, ctx)
463            .map_err(|_| InviteKeyBundleError::KeySealingFailed)?;
464        let invite_key_sealed_invite_data_cek = SymmetricKeyEnvelope::seal(
465            invite_data_cek,
466            invite_key,
467            SymmetricKeyEnvelopeNamespace::OrganizationInvite,
468            ctx,
469        )
470        .map_err(|_| InviteKeyBundleError::KeySealingFailed)?;
471
472        // Seal the invite key with the invite secret (invitee -> invite key direction).
473        let secret = HighEntropySecret::from(invite_secret.clone());
474        let invite_secret_sealed_invite_key = SecretProtectedKeyEnvelope::seal(
475            invite_key,
476            &secret,
477            SecretProtectedKeyEnvelopeNamespace::OrganizationInvite,
478            ctx,
479        )
480        .map_err(|_| InviteKeyBundleError::KeySealingFailed)?;
481
482        // Seal the organization key with the invite key (invite key -> organization key direction).
483        // New invites are created with confirmation enabled; callers can disable it afterwards via
484        // `Invite::disable_confirmation`.
485        let invite_key_sealed_organization_key = Some(
486            SymmetricKeyEnvelope::seal(
487                organization_key,
488                invite_key,
489                SymmetricKeyEnvelopeNamespace::OrganizationInvite,
490                ctx,
491            )
492            .map_err(|_| InviteKeyBundleError::KeySealingFailed)?,
493        );
494
495        // Seal the invite key with the organization key (organization key -> invite key direction).
496        let organization_key_sealed_invite_key = ctx
497            .wrap_symmetric_key(organization_key, invite_key)
498            .map_err(|_| InviteKeyBundleError::KeySealingFailed)?;
499
500        Ok((
501            invite_secret,
502            Invite {
503                sealed_invite_data,
504                invite_key_sealed_invite_data_cek,
505                invite_secret_sealed_invite_key,
506                invite_key_sealed_organization_key,
507                organization_key_sealed_invite_key,
508            },
509        ))
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use bitwarden_crypto::{
516        CoseKeyThumbprintExt, EncString, KeyStore, KeyStoreContext, PublicKeyEncryptionAlgorithm,
517        SymmetricCryptoKey, key_slot_ids,
518    };
519    use bitwarden_encoding::{B64, B64Url};
520
521    use crate::invite::{Invite, InviteKeyBundleError, InviteSecret};
522
523    // Test vectors captured from `generate_test_vectors`. They freeze a real organization key, the
524    // organization private key wrapped with it, and an invite, so backward compatibility (old data
525    // must remain decryptable) is verified by `test_invite_test_vector`.
526    const TEST_VECTOR_ORG_KEY: &str =
527        "KGP9Nc2/91w+42Z9VzY0m7h18avuZcq4ICM8Rhdc3BD92LbWS2TQkVBzavvUM684WKXiC22NJi2EwaiDW4YTAA==";
528    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=";
529    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="}"#;
530    const TEST_VECTOR_INVITE_SECRET: &str = "Ttas45_CvZi1yoFJ3bMCHx0DAAGGxDi-1BhHCutwDjI";
531
532    /// Loads the const `TEST_VECTOR_ORG_KEY` into the `Organization` slot and returns the parsed
533    /// const wrapped organization private key. Sharing fixed vectors keeps tests deterministic and
534    /// avoids generating a fresh RSA key on every run.
535    fn load_test_vectors(ctx: &mut KeyStoreContext<'_, TestIds>) -> EncString {
536        let org_key =
537            SymmetricCryptoKey::try_from(B64::try_from(TEST_VECTOR_ORG_KEY).unwrap()).unwrap();
538        let local_org_key_id = ctx.add_local_symmetric_key(org_key);
539        ctx.persist_symmetric_key(local_org_key_id, TestSymmKey::Organization)
540            .unwrap();
541        TEST_VECTOR_WRAPPED_PRIVATE_KEY.parse().unwrap()
542    }
543
544    #[test]
545    fn test_basic_invitation_bundle() {
546        let key_store = KeyStore::<TestIds>::default();
547        let mut ctx = key_store.context_mut();
548        let wrapped_private_key = load_test_vectors(&mut ctx);
549
550        let (secret1, _) =
551            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
552                .unwrap();
553        let (secret2, _) =
554            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
555                .unwrap();
556
557        assert_ne!(secret1, secret2);
558    }
559
560    #[test]
561    fn test_admin_recovers_invite_secret() {
562        let key_store = KeyStore::<TestIds>::default();
563        let mut ctx = key_store.context_mut();
564        let wrapped_private_key = load_test_vectors(&mut ctx);
565
566        let (invite_secret, invite) =
567            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
568                .unwrap();
569
570        let invite_key = invite
571            .unseal_invite_key_with_organization_key(TestSymmKey::Organization, &mut ctx)
572            .unwrap();
573        let recovered = invite.get_invite_secret(invite_key, &mut ctx).unwrap();
574
575        assert_eq!(invite_secret, recovered);
576    }
577
578    #[test]
579    fn test_admin_recovers_thumbprint() {
580        let key_store = KeyStore::<TestIds>::default();
581        let mut ctx = key_store.context_mut();
582        let wrapped_private_key = load_test_vectors(&mut ctx);
583
584        // The expected thumbprint is derived from the same wrapped private key that
585        // `make_for_private_key` binds into the invite.
586        let private_key_id = ctx
587            .unwrap_private_key(TestSymmKey::Organization, &wrapped_private_key)
588            .unwrap();
589        let expected = ctx
590            .get_public_key(private_key_id)
591            .unwrap()
592            .thumbprint()
593            .unwrap();
594
595        let (_, invite) =
596            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
597                .unwrap();
598
599        let invite_key = invite
600            .unseal_invite_key_with_organization_key(TestSymmKey::Organization, &mut ctx)
601            .unwrap();
602        let recovered = invite
603            .get_public_key_thumbprint(invite_key, &mut ctx)
604            .unwrap();
605
606        assert_eq!(recovered, expected);
607    }
608
609    #[test]
610    fn test_invitee_recovers_organization_key() {
611        let key_store = KeyStore::<TestIds>::default();
612        let mut ctx = key_store.context_mut();
613        let wrapped_private_key = load_test_vectors(&mut ctx);
614
615        let (invite_secret, invite) =
616            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
617                .unwrap();
618
619        // Using only the raw invite secret, an invitee can recover the invite key and then the
620        // organization key.
621        let invite_key = invite
622            .unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)
623            .unwrap();
624        let recovered_org_key_id = invite
625            .unseal_organization_key(invite_key, &mut ctx)
626            .unwrap();
627
628        ctx.assert_symmetric_keys_equal(recovered_org_key_id, TestSymmKey::Organization);
629    }
630
631    #[test]
632    fn test_confirmation_toggle() {
633        let key_store = KeyStore::<TestIds>::default();
634        let mut ctx = key_store.context_mut();
635        let wrapped_private_key = load_test_vectors(&mut ctx);
636
637        let (invite_secret, mut invite) =
638            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
639                .unwrap();
640
641        // New invites are created with confirmation enabled.
642        assert!(invite.supports_confirmation());
643
644        // Disabling confirmation removes the org-key envelope, so an invitee can no longer recover
645        // the organization key.
646        invite.disable_confirmation();
647        assert!(!invite.supports_confirmation());
648        let invite_key = invite
649            .unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)
650            .unwrap();
651        assert!(matches!(
652            invite.unseal_organization_key(invite_key, &mut ctx),
653            Err(InviteKeyBundleError::ConfirmationNotEnabled)
654        ));
655
656        // Re-enabling confirmation restores the invitee's ability to recover the organization key.
657        invite
658            .enable_confirmation(TestSymmKey::Organization, &mut ctx)
659            .unwrap();
660        assert!(invite.supports_confirmation());
661        let invite_key = invite
662            .unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)
663            .unwrap();
664        let recovered_org_key_id = invite
665            .unseal_organization_key(invite_key, &mut ctx)
666            .unwrap();
667        ctx.assert_symmetric_keys_equal(recovered_org_key_id, TestSymmKey::Organization);
668    }
669
670    #[test]
671    fn test_invite_string_round_trip() {
672        let key_store = KeyStore::<TestIds>::default();
673        let mut ctx = key_store.context_mut();
674        let wrapped_private_key = load_test_vectors(&mut ctx);
675
676        let (invite_secret, invite) =
677            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
678                .unwrap();
679
680        let encoded = String::from(&invite);
681        let decoded: Invite = encoded.parse().unwrap();
682        assert_eq!(String::from(&decoded), encoded);
683
684        // The decoded invite still recovers the invite secret.
685        let invite_key = decoded
686            .unseal_invite_key_with_organization_key(TestSymmKey::Organization, &mut ctx)
687            .unwrap();
688        let recovered = decoded.get_invite_secret(invite_key, &mut ctx).unwrap();
689        assert_eq!(invite_secret, recovered);
690
691        // The custom serde impls delegate to the string round-trip: an invite must serialize as a
692        // single string, not as a struct. Callers (notably the WASM bindings, which type an invite
693        // as `Tagged<string, "Invite">`) depend on this.
694        let json = serde_json::to_string(&invite).unwrap();
695        assert_eq!(json, serde_json::to_string(&encoded).unwrap());
696        let from_json: Invite = serde_json::from_str(&json).unwrap();
697        assert_eq!(String::from(&from_json), encoded);
698    }
699
700    #[test]
701    fn test_wrong_invite_secret_fails() {
702        let key_store = KeyStore::<TestIds>::default();
703        let mut ctx = key_store.context_mut();
704        let wrapped_private_key = load_test_vectors(&mut ctx);
705
706        let (_, invite) =
707            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
708                .unwrap();
709
710        // A different invite secret cannot unseal the invite key.
711        let wrong_secret = InviteSecret(zeroize::Zeroizing::new([7u8; 32]));
712        assert!(matches!(
713            invite.unseal_invite_key_with_invite_secret(&wrong_secret, &mut ctx),
714            Err(InviteKeyBundleError::KeyUnsealingFailed)
715        ));
716    }
717
718    #[test]
719    fn test_wrong_organization_key_fails() {
720        let key_store = KeyStore::<TestIds>::default();
721        let mut ctx = key_store.context_mut();
722        let wrapped_private_key = load_test_vectors(&mut ctx);
723
724        let (_, invite) =
725            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
726                .unwrap();
727
728        // A different organization key cannot unwrap the invite key.
729        let wrong_org_key = ctx.generate_symmetric_key();
730        assert!(matches!(
731            invite.unseal_invite_key_with_organization_key(wrong_org_key, &mut ctx),
732            Err(InviteKeyBundleError::KeyUnsealingFailed)
733        ));
734    }
735
736    #[test]
737    fn test_invitee_recovers_thumbprint() {
738        let key_store = KeyStore::<TestIds>::default();
739        let mut ctx = key_store.context_mut();
740        let wrapped_private_key = load_test_vectors(&mut ctx);
741
742        let private_key_id = ctx
743            .unwrap_private_key(TestSymmKey::Organization, &wrapped_private_key)
744            .unwrap();
745        let expected = ctx
746            .get_public_key(private_key_id)
747            .unwrap()
748            .thumbprint()
749            .unwrap();
750
751        let (invite_secret, invite) =
752            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
753                .unwrap();
754
755        // The invitee reaches the invite key via the invite secret and reads the same bound
756        // thumbprint the admin would.
757        let invite_key = invite
758            .unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)
759            .unwrap();
760        let recovered = invite
761            .get_public_key_thumbprint(invite_key, &mut ctx)
762            .unwrap();
763
764        assert_eq!(recovered, expected);
765    }
766
767    #[test]
768    fn test_malformed_invite_string_fails() {
769        // Not JSON at all.
770        assert!(matches!(
771            "not valid json !!!".parse::<Invite>(),
772            Err(InviteKeyBundleError::DecodingFailed)
773        ));
774
775        // Valid JSON, but missing the required invite fields.
776        assert!(matches!(
777            "{}".parse::<Invite>(),
778            Err(InviteKeyBundleError::DecodingFailed)
779        ));
780    }
781
782    #[test]
783    fn test_invalid_wrapped_private_key_fails() {
784        let key_store = KeyStore::<TestIds>::default();
785        let mut ctx = key_store.context_mut();
786        let wrapped_private_key = load_test_vectors(&mut ctx);
787
788        // The wrapped private key can only be unwrapped with the organization key it was wrapped
789        // with; a different key makes `make_for_private_key` fail before building the invite.
790        let wrong_org_key = ctx.generate_symmetric_key();
791        assert!(matches!(
792            Invite::make_for_private_key(wrong_org_key, &wrapped_private_key, &mut ctx),
793            Err(InviteKeyBundleError::InvalidPrivateKey)
794        ));
795    }
796
797    #[test]
798    fn test_invite_secret_into_base64_url() {
799        let data: [u8; 32] = *b"+/=Hello, World!AAAAAAAAAAAAAAAA";
800        let secret = InviteSecret(zeroize::Zeroizing::new(data));
801
802        let encoded = String::from(&secret);
803
804        assert_eq!(encoded, "Ky89SGVsbG8sIFdvcmxkIUFBQUFBQUFBQUFBQUFBQUE");
805        assert!(!encoded.contains('+'));
806        assert!(!encoded.contains('/'));
807        assert!(!encoded.contains('='));
808
809        let decoded = B64Url::try_from(encoded.as_str()).unwrap();
810        assert_eq!(decoded.as_bytes(), data);
811
812        // Round-trips back to the same invite secret.
813        let reparsed: InviteSecret = encoded.parse().unwrap();
814        assert_eq!(reparsed, secret);
815    }
816
817    #[test]
818    #[ignore = "Manual test to generate test vectors"]
819    fn generate_test_vectors() {
820        let key_store = KeyStore::<TestIds>::default();
821        let mut ctx = key_store.context_mut();
822
823        let org_key =
824            SymmetricCryptoKey::try_from(B64::try_from(TEST_VECTOR_ORG_KEY).unwrap()).unwrap();
825        let org_key_id = ctx.add_local_symmetric_key(org_key);
826
827        // Make and wrap a fresh organization private key so it can be recorded as a fixed vector.
828        let private_key_id = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
829        let wrapped_private_key = ctx.wrap_private_key(org_key_id, private_key_id).unwrap();
830
831        let (invite_secret, invite) =
832            Invite::make_for_private_key(org_key_id, &wrapped_private_key, &mut ctx).unwrap();
833
834        println!(
835            "const TEST_VECTOR_WRAPPED_PRIVATE_KEY: &str = \"{}\";",
836            wrapped_private_key.to_string()
837        );
838        println!(
839            "const TEST_VECTOR_INVITE: &str = r#\"{}\"#;",
840            String::from(&invite)
841        );
842        println!(
843            "const TEST_VECTOR_INVITE_SECRET: &str = \"{}\";",
844            String::from(&invite_secret)
845        );
846    }
847
848    #[test]
849    fn test_invite_test_vector() {
850        let key_store = KeyStore::<TestIds>::default();
851        let mut ctx = key_store.context_mut();
852
853        let org_key =
854            SymmetricCryptoKey::try_from(B64::try_from(TEST_VECTOR_ORG_KEY).unwrap()).unwrap();
855        let org_key_id = ctx.add_local_symmetric_key(org_key);
856
857        let invite: Invite = TEST_VECTOR_INVITE.parse().unwrap();
858        let invite_key = invite
859            .unseal_invite_key_with_organization_key(org_key_id, &mut ctx)
860            .unwrap();
861        let recovered = invite.get_invite_secret(invite_key, &mut ctx).unwrap();
862
863        assert_eq!(String::from(&recovered), TEST_VECTOR_INVITE_SECRET);
864    }
865
866    #[test]
867    #[ignore = "Manual test to verify debug format"]
868    fn test_debug() {
869        let key_store = KeyStore::<TestIds>::default();
870        let mut ctx = key_store.context_mut();
871        let wrapped_private_key = load_test_vectors(&mut ctx);
872
873        let (invite_secret, invite) =
874            Invite::make_for_private_key(TestSymmKey::Organization, &wrapped_private_key, &mut ctx)
875                .unwrap();
876        println!("{invite_secret:?}");
877        println!("{invite:?}");
878    }
879
880    key_slot_ids! {
881        #[symmetric]
882        pub enum TestSymmKey {
883            Organization,
884            #[local]
885            Local(LocalId),
886        }
887
888        #[private]
889        pub enum TestPrivateKey {
890            A(u8),
891            B,
892            #[local]
893            C(LocalId),
894        }
895
896        #[signing]
897        pub enum TestSigningKey {
898            A(u8),
899            B,
900            #[local]
901            C(LocalId),
902        }
903
904       pub TestIds => TestSymmKey, TestPrivateKey, TestSigningKey;
905    }
906}