-
Notifications
You must be signed in to change notification settings - Fork 41
Auth/PM-40520 - Registration - Open Org Invite Seal / Unseal Support #1295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
JaredSnider-Bitwarden
merged 40 commits into
main
from
auth/pm-40520/registration-open-org-invite-seal-unseal
Jul 29, 2026
Merged
Changes from 8 commits
Commits
Show all changes
40 commits
Select commit
Hold shift + click to select a range
c59e915
PM-40520 - Add HighEntropySecret base64 wire helpers
JaredSnider-Bitwarden 447cf66
PM-40520 - Add namespace variants for open-invite registration crossing
JaredSnider-Bitwarden b424f8f
PM-40520 - Add RegistrationClient seal/unseal for open-org-invite croβ¦
JaredSnider-Bitwarden 99e1dc1
PM-40520 - Move open-org-invite WASM tests to auth-owned tests/registβ¦
JaredSnider-Bitwarden 238e3bb
PM-40520 - Route tests/registration/** to team-auth-dev in CODEOWNERS
JaredSnider-Bitwarden e843bb4
PM-40520 - Align new WASM-facing types on camelCase serde
JaredSnider-Bitwarden 8509fbe
PM-40520 - Enforce MIN_SECRET_LENGTH in HighEntropySecret::from_base64
JaredSnider-Bitwarden d539696
PM-40520 - Force CBOR bstr encoding for the two envelope bytes fields
JaredSnider-Bitwarden bd2af69
PM-40520 - Teach generate_versioned_sealable! optional visibility
JaredSnider-Bitwarden 4545a48
PM-40520 - Restructure open-invite into module folder, drop macro change
JaredSnider-Bitwarden 62e9925
PM-40520 - Trim internals from public rustdoc on open-invite types
JaredSnider-Bitwarden f93070a
PM-40520 - Inline seal/unseal bodies into their impl methods
JaredSnider-Bitwarden 4ee3240
PM-40520 - Correct open-org-invite module doc to match app flow
JaredSnider-Bitwarden ba652a2
PM-40520 - HighEntropySecret uses the DataEnvelope trait pattern
JaredSnider-Bitwarden 8314bc9
PM-40520 - Strongly type SealedOpenOrgInvite fields
JaredSnider-Bitwarden 5874d84
PM-40520 - Extract OPEN_ORG_INVITE_SECRET_SIZE_BYTES const
JaredSnider-Bitwarden 3e556bb
PM-40520 - Add public-API integration tests + pinned wire vector
JaredSnider-Bitwarden a8b6c65
PM-40520 - Small doc + TS-type touch-ups from PR review
JaredSnider-Bitwarden 8f2f989
PM-40520 - Scope unseal doc to post-parse failures
JaredSnider-Bitwarden 3ba969f
PM-40520 - Rename wire_v1 to data_v1 and clarify layer docs
JaredSnider-Bitwarden 9d2a514
PM-40520 - Rename SealedEnvelopePair to SealedOpenOrgInviteData
JaredSnider-Bitwarden 60054b9
PM-40520 - Fill test gaps for HighEntropySecret serde + key-envelope β¦
JaredSnider-Bitwarden 791d079
Merge branch 'main' into auth/pm-40520/registration-open-org-invite-sβ¦
JaredSnider-Bitwarden 2fd721f
PM-40520 - Rename OpenOrgInviteSealRequest to OpenOrgInvite
JaredSnider-Bitwarden 29f5a0f
PM-40520 - Fully-qualify cross-crate intra-doc links
JaredSnider-Bitwarden 86e80b3
PM-40520 - Adjust SecretProtectedKeyEnvelopeNamespace per feedback
JaredSnider-Bitwarden 3ccff9e
PM-40520 - remove overdocumented comments
JaredSnider-Bitwarden ba7fbd5
PM-40520 - Rename invite_key to invite_secret
JaredSnider-Bitwarden 21a1c26
PM-40520 - Adjust OPEN_ORG_INVITE_SECRET_SIZE_BYTES comment to be shoβ¦
JaredSnider-Bitwarden 44f21d7
PM-40520 - fix OpenOrgInvite docs
JaredSnider-Bitwarden 9ed4201
PM-40520 - Remove low level comment paragraph per PR feedback
JaredSnider-Bitwarden ff72d85
PM-40520 - Trim wordy comments in seal, unseal, and integration tests
JaredSnider-Bitwarden af343e3
Merge remote-tracking branch 'origin/main' into auth/pm-40520/registrβ¦
JaredSnider-Bitwarden e7a936f
PM-40520 - Rename inviteKey to inviteSecret in integration test + fixβ¦
JaredSnider-Bitwarden a40ca8b
PM-40520 - Move seal/unseal logic onto SealedOpenOrgInviteData
JaredSnider-Bitwarden 68a3357
PM-40520 - Trim SealedOpenOrgInviteDataWire doc, move serde_bytes ratβ¦
JaredSnider-Bitwarden 5c5deb7
PM-40520 - Define CBOR and WASM ABI inline in serialization module doc
JaredSnider-Bitwarden b45ac31
PM-40520 - Update stale SealedEnvelopePair reference in error message
JaredSnider-Bitwarden 8518461
Merge remote-tracking branch 'origin/main' into auth/pm-40520/registrβ¦
JaredSnider-Bitwarden abc1810
PM-40520 - Restore .github/CODEOWNERS after merge resolution ran it tβ¦
JaredSnider-Bitwarden File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
42 changes: 42 additions & 0 deletions
42
crates/bitwarden-auth/src/registration/open_org_invite_data.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| //! Versioned sealable payload for the open-organization-invite registration crossing. | ||
| //! | ||
| //! The payload structs live in their own file so both [`super::seal_open_org_invite_data`] and | ||
| //! [`super::unseal_open_org_invite_data`] can reference them. The versioned enum matches what | ||
| //! `bitwarden_crypto::generate_versioned_sealable!` would emit, expanded manually so it can be | ||
| //! `pub(super)` and accessible across the sibling modules. | ||
|
|
||
| use bitwarden_crypto::safe::{DataEnvelopeNamespace, SealableData, SealableVersionedData}; | ||
| use serde::{Deserialize, Serialize}; | ||
|
|
||
| /// Version 1 wire schema of the open-invite registration payload. | ||
| /// | ||
| /// Once this shape ships it cannot be broken; if the fields change, add a new `V2` struct and | ||
| /// register both variants on [`RegistrationOpenOrgInviteData`]. | ||
| #[derive(Serialize, Deserialize, Debug, PartialEq)] | ||
| #[serde(rename_all = "camelCase")] | ||
| pub(super) struct RegistrationOpenOrgInviteDataV1 { | ||
| pub(super) organization_id: String, | ||
| pub(super) invite_link_code: String, | ||
| pub(super) invite_key: String, | ||
| } | ||
|
|
||
| impl SealableData for RegistrationOpenOrgInviteDataV1 {} | ||
|
|
||
| /// Adjacently tagged versioned wrapper. Serialization uses `{"version": "1", "content": {...}}`, | ||
| /// matching the shape `generate_versioned_sealable!` emits. | ||
| #[derive(Serialize, Deserialize, Debug, PartialEq)] | ||
| #[serde(tag = "version", content = "content")] | ||
| pub(super) enum RegistrationOpenOrgInviteData { | ||
| #[serde(rename = "1")] | ||
| V1(RegistrationOpenOrgInviteDataV1), | ||
| } | ||
|
|
||
| impl SealableVersionedData for RegistrationOpenOrgInviteData { | ||
| const NAMESPACE: DataEnvelopeNamespace = DataEnvelopeNamespace::RegistrationOpenOrgInviteData; | ||
| } | ||
|
|
||
| impl From<RegistrationOpenOrgInviteDataV1> for RegistrationOpenOrgInviteData { | ||
| fn from(value: RegistrationOpenOrgInviteDataV1) -> Self { | ||
| Self::V1(value) | ||
| } | ||
| } | ||
220 changes: 220 additions & 0 deletions
220
crates/bitwarden-auth/src/registration/seal_open_org_invite_data.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,220 @@ | ||
| //! Seals an open-organization-invite context into an opaque wire artifact plus a paired | ||
| //! [`HighEntropySecret`]. The sealed data rides the verification email URL; the secret stays | ||
| //! client-side. Both are needed to recover the original invite context via | ||
| //! [`super::unseal_open_org_invite_data`]. | ||
| //! | ||
| //! Two-envelope construction mirrors the pattern in | ||
| //! `bitwarden-organization-crypto/src/invite_key_bundle.rs`: | ||
| //! - inner [`DataEnvelope`] seals the [`RegistrationOpenOrgInviteData`] payload with a freshly | ||
| //! generated content-encryption key (CEK); | ||
| //! - outer [`SecretProtectedKeyEnvelope`] seals that CEK with a per-registration | ||
| //! [`HighEntropySecret`]. | ||
| //! | ||
| //! Substitution defense at both layers is the AES-GCM auth tag / wrong-key check β the SDK | ||
| //! performs no post-decrypt equality check on the plaintext, matching how | ||
| //! `Invite::unseal` in `invite_key_bundle` trusts the crypto. | ||
|
|
||
| use bitwarden_core::{Client, key_management::KeySlotIds}; | ||
| use bitwarden_crypto::{ | ||
| KeyStore, | ||
| safe::{ | ||
| DataEnvelope, HighEntropySecret, SecretProtectedKeyEnvelope, | ||
| SecretProtectedKeyEnvelopeNamespace, | ||
| }, | ||
| }; | ||
| use bitwarden_encoding::B64Url; | ||
| use serde::{Deserialize, Serialize}; | ||
| #[cfg(feature = "wasm")] | ||
| use tsify::Tsify; | ||
| #[cfg(feature = "wasm")] | ||
| use wasm_bindgen::prelude::*; | ||
|
|
||
| use super::{ | ||
| open_org_invite_data::{RegistrationOpenOrgInviteData, RegistrationOpenOrgInviteDataV1}, | ||
| registration_client::{RegistrationClient, RegistrationError}, | ||
| }; | ||
|
|
||
| /// The plaintext invite context that a registrant will consume on the verification-email tab to | ||
| /// complete the open-organization-invite acceptance. All three fields are required. | ||
| #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] | ||
| #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] | ||
| #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] | ||
| #[serde(rename_all = "camelCase")] | ||
| pub struct OpenOrgInviteData { | ||
| /// The organization the registrant is joining. | ||
| pub organization_id: String, | ||
| /// The public invite link code (also carried in the shared invite URL). | ||
| pub invite_link_code: String, | ||
| /// The invite key associated with the invite link. Never leaves the client except in | ||
| /// pre-sealed form. | ||
| pub invite_key: String, | ||
| } | ||
|
|
||
| /// The output of [`RegistrationClient::seal_open_org_invite_data`]. Both fields are opaque wire | ||
| /// artifacts safe to transport at layer boundaries; both are needed to unseal. | ||
| #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] | ||
| #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] | ||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||
| #[serde(rename_all = "camelCase")] | ||
| pub struct SealedOpenOrgInvite { | ||
| /// Base64url-encoded CBOR wrapper containing the inner data envelope and outer key envelope. | ||
| /// Rides the verification email URL as an opaque blob. | ||
| pub sealed_data: String, | ||
| /// Standardized base64 of the per-registration [`HighEntropySecret`]. Stays client-side | ||
| /// (e.g. `localStorage`) β never sent to the server. | ||
| pub high_entropy_secret: String, | ||
| } | ||
|
|
||
| #[cfg_attr(feature = "wasm", wasm_bindgen)] | ||
| impl RegistrationClient { | ||
| /// Seals an open-organization-invite context so the registrant can carry it across the | ||
| /// verification-email tab boundary. Returns both halves β the sealed data (server-visible) | ||
| /// and the paired high-entropy secret (client-only). See module-level docs for the | ||
| /// two-envelope construction. | ||
| pub fn seal_open_org_invite_data( | ||
| &self, | ||
| input: OpenOrgInviteData, | ||
| ) -> Result<SealedOpenOrgInvite, RegistrationError> { | ||
| internal_seal_open_org_invite_data(&self.client, input) | ||
| } | ||
| } | ||
|
|
||
| fn internal_seal_open_org_invite_data( | ||
| _client: &Client, | ||
| input: OpenOrgInviteData, | ||
| ) -> Result<SealedOpenOrgInvite, RegistrationError> { | ||
| // The CEK is transient and never persists across calls. A per-call `KeyStore` keeps the | ||
| // key material scoped to this operation so nothing lingers in the caller's key store. | ||
| let key_store: KeyStore<KeySlotIds> = KeyStore::default(); | ||
| let mut ctx = key_store.context_mut(); | ||
|
|
||
| let high_entropy_secret = HighEntropySecret::make(32).map_err(|_| RegistrationError::Crypto)?; | ||
|
|
||
| let versioned: RegistrationOpenOrgInviteData = RegistrationOpenOrgInviteDataV1 { | ||
| organization_id: input.organization_id, | ||
| invite_link_code: input.invite_link_code, | ||
| invite_key: input.invite_key, | ||
| } | ||
| .into(); | ||
|
|
||
| let (data_envelope, cek_id) = | ||
| DataEnvelope::seal(versioned, &mut ctx).map_err(|_| RegistrationError::Crypto)?; | ||
|
|
||
| let key_envelope = SecretProtectedKeyEnvelope::seal( | ||
| cek_id, | ||
| &high_entropy_secret, | ||
| SecretProtectedKeyEnvelopeNamespace::RegistrationOpenOrgInvite, | ||
| &ctx, | ||
| ) | ||
| .map_err(|_| RegistrationError::Crypto)?; | ||
|
|
||
| let sealed_data = combine_envelopes(&data_envelope, &key_envelope)?; | ||
|
|
||
| Ok(SealedOpenOrgInvite { | ||
| sealed_data, | ||
| high_entropy_secret: high_entropy_secret.to_base64(), | ||
| }) | ||
| } | ||
|
|
||
| /// Internal CBOR wire schema for `sealed_data`. Each field is the direct serialization of the | ||
| /// respective envelope type, forced to CBOR `bstr` (major type 2) via `serde_bytes`. Without | ||
| /// that hint, serde's default `Vec<u8>` handling would emit a CBOR array of integers β one | ||
| /// element per byte, roughly doubling the encoded size β which defeats the point of nesting | ||
| /// raw bytes here instead of the envelopes' own base64 string forms. | ||
| #[derive(Serialize, Deserialize)] | ||
| pub(super) struct SealedEnvelopePair { | ||
| #[serde(rename = "d", with = "serde_bytes")] | ||
| pub(super) data_envelope: Vec<u8>, | ||
| #[serde(rename = "k", with = "serde_bytes")] | ||
| pub(super) key_envelope: Vec<u8>, | ||
| } | ||
|
|
||
| /// CBOR-encodes the two envelopes and outer-base64url-encodes the result. Base64url keeps the | ||
| /// output URL-safe, since the sealed data rides the verification email URL fragment. | ||
| pub(super) fn combine_envelopes( | ||
| data_envelope: &DataEnvelope, | ||
| key_envelope: &SecretProtectedKeyEnvelope, | ||
| ) -> Result<String, RegistrationError> { | ||
| let pair = SealedEnvelopePair { | ||
| data_envelope: data_envelope.into(), | ||
| key_envelope: key_envelope.into(), | ||
| }; | ||
| let mut buf = Vec::new(); | ||
| ciborium::ser::into_writer(&pair, &mut buf).map_err(|_| RegistrationError::Crypto)?; | ||
| Ok(B64Url::from(buf).to_string()) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| fn sample_input() -> OpenOrgInviteData { | ||
| OpenOrgInviteData { | ||
| organization_id: "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".to_string(), | ||
| invite_link_code: "abcd1234efgh5678".to_string(), | ||
| invite_key: "raw-invite-key-material-base64url".to_string(), | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn seal_returns_non_empty_sealed_data_and_high_entropy_secret() { | ||
| let client = Client::new(None); | ||
| let registration_client = RegistrationClient::new(client); | ||
|
|
||
| let sealed = registration_client | ||
| .seal_open_org_invite_data(sample_input()) | ||
| .expect("seal should succeed"); | ||
|
|
||
| assert!(!sealed.sealed_data.is_empty()); | ||
| assert!(!sealed.high_entropy_secret.is_empty()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn seal_sealed_data_is_valid_base64url() { | ||
| let client = Client::new(None); | ||
| let registration_client = RegistrationClient::new(client); | ||
|
|
||
| let sealed = registration_client | ||
| .seal_open_org_invite_data(sample_input()) | ||
| .expect("seal should succeed"); | ||
|
|
||
| // Wire-format sanity: sealed_data must decode as base64url and re-encode identically. | ||
| let decoded = B64Url::try_from(sealed.sealed_data.as_str()) | ||
| .expect("sealed_data must be valid base64url"); | ||
| assert_eq!( | ||
| B64Url::from(decoded.as_bytes()).to_string(), | ||
| sealed.sealed_data | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn seal_high_entropy_secret_is_valid_base64() { | ||
| let client = Client::new(None); | ||
| let registration_client = RegistrationClient::new(client); | ||
|
|
||
| let sealed = registration_client | ||
| .seal_open_org_invite_data(sample_input()) | ||
| .expect("seal should succeed"); | ||
|
|
||
| // The secret must be a HighEntropySecret round-trippable base64 string. | ||
| HighEntropySecret::from_base64(&sealed.high_entropy_secret) | ||
| .expect("high_entropy_secret must be valid base64"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn two_seals_produce_distinct_secrets_and_data() { | ||
| let client = Client::new(None); | ||
| let registration_client = RegistrationClient::new(client); | ||
|
|
||
| let first = registration_client | ||
| .seal_open_org_invite_data(sample_input()) | ||
| .expect("first seal should succeed"); | ||
| let second = registration_client | ||
| .seal_open_org_invite_data(sample_input()) | ||
| .expect("second seal should succeed"); | ||
|
|
||
| // Per-registration randomness: fresh CEK + fresh HighEntropySecret + fresh HKDF salt. | ||
| assert_ne!(first.high_entropy_secret, second.high_entropy_secret); | ||
| assert_ne!(first.sealed_data, second.sealed_data); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.