Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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 Jul 21, 2026
447cf66
PM-40520 - Add namespace variants for open-invite registration crossing
JaredSnider-Bitwarden Jul 21, 2026
b424f8f
PM-40520 - Add RegistrationClient seal/unseal for open-org-invite cro…
JaredSnider-Bitwarden Jul 21, 2026
99e1dc1
PM-40520 - Move open-org-invite WASM tests to auth-owned tests/regist…
JaredSnider-Bitwarden Jul 22, 2026
238e3bb
PM-40520 - Route tests/registration/** to team-auth-dev in CODEOWNERS
JaredSnider-Bitwarden Jul 22, 2026
e843bb4
PM-40520 - Align new WASM-facing types on camelCase serde
JaredSnider-Bitwarden Jul 22, 2026
8509fbe
PM-40520 - Enforce MIN_SECRET_LENGTH in HighEntropySecret::from_base64
JaredSnider-Bitwarden Jul 22, 2026
d539696
PM-40520 - Force CBOR bstr encoding for the two envelope bytes fields
JaredSnider-Bitwarden Jul 22, 2026
bd2af69
PM-40520 - Teach generate_versioned_sealable! optional visibility
JaredSnider-Bitwarden Jul 22, 2026
4545a48
PM-40520 - Restructure open-invite into module folder, drop macro change
JaredSnider-Bitwarden Jul 22, 2026
62e9925
PM-40520 - Trim internals from public rustdoc on open-invite types
JaredSnider-Bitwarden Jul 22, 2026
f93070a
PM-40520 - Inline seal/unseal bodies into their impl methods
JaredSnider-Bitwarden Jul 22, 2026
4ee3240
PM-40520 - Correct open-org-invite module doc to match app flow
JaredSnider-Bitwarden Jul 22, 2026
ba652a2
PM-40520 - HighEntropySecret uses the DataEnvelope trait pattern
JaredSnider-Bitwarden Jul 22, 2026
8314bc9
PM-40520 - Strongly type SealedOpenOrgInvite fields
JaredSnider-Bitwarden Jul 22, 2026
5874d84
PM-40520 - Extract OPEN_ORG_INVITE_SECRET_SIZE_BYTES const
JaredSnider-Bitwarden Jul 22, 2026
3e556bb
PM-40520 - Add public-API integration tests + pinned wire vector
JaredSnider-Bitwarden Jul 22, 2026
a8b6c65
PM-40520 - Small doc + TS-type touch-ups from PR review
JaredSnider-Bitwarden Jul 22, 2026
8f2f989
PM-40520 - Scope unseal doc to post-parse failures
JaredSnider-Bitwarden Jul 23, 2026
3ba969f
PM-40520 - Rename wire_v1 to data_v1 and clarify layer docs
JaredSnider-Bitwarden Jul 23, 2026
9d2a514
PM-40520 - Rename SealedEnvelopePair to SealedOpenOrgInviteData
JaredSnider-Bitwarden Jul 23, 2026
60054b9
PM-40520 - Fill test gaps for HighEntropySecret serde + key-envelope …
JaredSnider-Bitwarden Jul 23, 2026
791d079
Merge branch 'main' into auth/pm-40520/registration-open-org-invite-s…
JaredSnider-Bitwarden Jul 23, 2026
2fd721f
PM-40520 - Rename OpenOrgInviteSealRequest to OpenOrgInvite
JaredSnider-Bitwarden Jul 23, 2026
29f5a0f
PM-40520 - Fully-qualify cross-crate intra-doc links
JaredSnider-Bitwarden Jul 23, 2026
86e80b3
PM-40520 - Adjust SecretProtectedKeyEnvelopeNamespace per feedback
JaredSnider-Bitwarden Jul 24, 2026
3ccff9e
PM-40520 - remove overdocumented comments
JaredSnider-Bitwarden Jul 24, 2026
ba7fbd5
PM-40520 - Rename invite_key to invite_secret
JaredSnider-Bitwarden Jul 24, 2026
21a1c26
PM-40520 - Adjust OPEN_ORG_INVITE_SECRET_SIZE_BYTES comment to be sho…
JaredSnider-Bitwarden Jul 24, 2026
44f21d7
PM-40520 - fix OpenOrgInvite docs
JaredSnider-Bitwarden Jul 24, 2026
9ed4201
PM-40520 - Remove low level comment paragraph per PR feedback
JaredSnider-Bitwarden Jul 24, 2026
ff72d85
PM-40520 - Trim wordy comments in seal, unseal, and integration tests
JaredSnider-Bitwarden Jul 24, 2026
af343e3
Merge remote-tracking branch 'origin/main' into auth/pm-40520/registr…
JaredSnider-Bitwarden Jul 24, 2026
e7a936f
PM-40520 - Rename inviteKey to inviteSecret in integration test + fix…
JaredSnider-Bitwarden Jul 24, 2026
a40ca8b
PM-40520 - Move seal/unseal logic onto SealedOpenOrgInviteData
JaredSnider-Bitwarden Jul 27, 2026
68a3357
PM-40520 - Trim SealedOpenOrgInviteDataWire doc, move serde_bytes rat…
JaredSnider-Bitwarden Jul 27, 2026
5c5deb7
PM-40520 - Define CBOR and WASM ABI inline in serialization module doc
JaredSnider-Bitwarden Jul 27, 2026
b45ac31
PM-40520 - Update stale SealedEnvelopePair reference in error message
JaredSnider-Bitwarden Jul 27, 2026
8518461
Merge remote-tracking branch 'origin/main' into auth/pm-40520/registr…
JaredSnider-Bitwarden Jul 28, 2026
abc1810
PM-40520 - Restore .github/CODEOWNERS after merge resolution ran it t…
JaredSnider-Bitwarden Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ crates/bitwarden-vault/** @bitwarden/team-vault-dev

# Team-owned folders in other crates (to be avoided if possible)
crates/bitwarden-wasm-internal/integration-tests/tests/invite/** @bitwarden/team-admin-console-dev
crates/bitwarden-wasm-internal/integration-tests/tests/registration/** @bitwarden/team-auth-dev
crates/bitwarden-wasm-internal/integration-tests/tests/unlock/** @bitwarden/team-key-management-dev
crates/bitwarden-wasm-internal/src/pure_crypto.rs @bitwarden/team-key-management-dev
crates/bitwarden-core/src/key_management/** @bitwarden/team-key-management-dev @bitwarden/team-platform-dev
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 4 additions & 7 deletions crates/bitwarden-auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,9 @@ wasm = [
"dep:tsify",
"dep:wasm-bindgen",
"dep:wasm-bindgen-futures",
] # WASM support
uniffi = [
"bitwarden-core/uniffi",
"bitwarden-policies/uniffi",
"dep:uniffi",
] # Uniffi bindings
secrets = ["bitwarden-core/secrets"] # Secrets Manager support
]
uniffi = ["bitwarden-core/uniffi", "bitwarden-policies/uniffi", "dep:uniffi"]
secrets = ["bitwarden-core/secrets"]

# Note: dependencies must be alphabetized to pass the cargo sort check in the CI pipeline.
[dependencies]
Expand All @@ -43,6 +39,7 @@ bitwarden-error = { workspace = true }
bitwarden-policies = { workspace = true }
bitwarden-state = { workspace = true }
chrono = { workspace = true }
ciborium = { workspace = true }
http = { workspace = true }
reqwest = { workspace = true }
reqwest-middleware = { workspace = true }
Expand Down
4 changes: 4 additions & 0 deletions crates/bitwarden-auth/src/registration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
//! mechanisms to establish their cryptographic state and register with
//! the Bitwarden server

mod open_org_invite_data;
mod post_keys_for_jit_password_registration;
mod post_keys_for_key_connector_registration;
mod post_keys_for_tde_registration;
mod post_keys_for_user_password_registration;
mod registration_client;
mod seal_open_org_invite_data;
mod unseal_open_org_invite_data;

pub use post_keys_for_jit_password_registration::{
JitMasterPasswordRegistrationRequest, JitMasterPasswordRegistrationResponse,
Expand All @@ -18,3 +21,4 @@ pub use post_keys_for_user_password_registration::{
UserMasterPasswordRegistrationRequest, UserMasterPasswordRegistrationResponse,
};
pub use registration_client::{RegistrationClient, RegistrationError};
pub use seal_open_org_invite_data::{OpenOrgInviteData, SealedOpenOrgInvite};
32 changes: 32 additions & 0 deletions crates/bitwarden-auth/src/registration/open_org_invite_data.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! 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
//! `generate_versioned_sealable!` invocation uses its optional visibility prefix to emit a
//! `pub(super)` enum so the sibling modules can see it.

use bitwarden_crypto::{
generate_versioned_sealable,
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 {}

generate_versioned_sealable!(
pub(super) RegistrationOpenOrgInviteData,
DataEnvelopeNamespace::RegistrationOpenOrgInviteData,
[RegistrationOpenOrgInviteDataV1 => "1"]
);
220 changes: 220 additions & 0 deletions crates/bitwarden-auth/src/registration/seal_open_org_invite_data.rs
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);
}
}
Loading
Loading