Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/organizations/** @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,12 +3,16 @@
//! mechanisms to establish their cryptographic state and register with
//! the Bitwarden server

mod open_org_invite_crypto;
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;

pub use open_org_invite_crypto::{
OpenOrgInvite, SealedOpenOrgInvite, SealedOpenOrgInviteData, SealedOpenOrgInviteDataError,
};
pub use post_keys_for_jit_password_registration::{
JitMasterPasswordRegistrationRequest, JitMasterPasswordRegistrationResponse,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//! FFI-facing seal/unseal. The [`RegistrationClient`] methods are thin wrappers over
//! [`SealedOpenOrgInviteData::seal`] / [`SealedOpenOrgInviteData::unseal`];
//! [`SealedOpenOrgInvite`] bundles both halves of the seal output as one WASM return type.

use bitwarden_crypto::safe::HighEntropySecret;
use serde::{Deserialize, Serialize};
#[cfg(feature = "wasm")]
use tsify::Tsify;
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::*;

use super::{OpenOrgInvite, SealedOpenOrgInviteData};
use crate::registration::registration_client::{RegistrationClient, RegistrationError};

/// Sealed open-organization-invite payload. Produced by
/// [`RegistrationClient::seal_open_org_invite_data`] and consumed by
/// [`RegistrationClient::unseal_open_org_invite_data`]. Both fields are required to unseal;
/// neither half is useful on its own.
#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SealedOpenOrgInvite {
/// URL-safe opaque payload; place on the verification-email link.
pub sealed_data: SealedOpenOrgInviteData,
/// Paired secret; keep client-side (e.g. `localStorage`) and never send to the server.
pub high_entropy_secret: HighEntropySecret,
}

#[cfg_attr(feature = "wasm", wasm_bindgen)]
impl RegistrationClient {
/// Seals an [`OpenOrgInvite`] into a [`SealedOpenOrgInvite`]. The returned
/// `sealed_data` is safe to place on the verification-email link; the returned
/// `high_entropy_secret` must stay client-side.
pub fn seal_open_org_invite_data(
&self,
input: OpenOrgInvite,
) -> Result<SealedOpenOrgInvite, RegistrationError> {
let (sealed_data, high_entropy_secret) = SealedOpenOrgInviteData::seal(input)?;
Ok(SealedOpenOrgInvite {
sealed_data,
high_entropy_secret,
})
}

/// Unseals a [`SealedOpenOrgInvite`] back into an [`OpenOrgInvite`]. Returns
/// [`RegistrationError::Crypto`] if the paired secret does not match the sealed payload or
/// the payload has been tampered with.
pub fn unseal_open_org_invite_data(
&self,
sealed: SealedOpenOrgInvite,
) -> Result<OpenOrgInvite, RegistrationError> {
sealed.sealed_data.unseal(&sealed.high_entropy_secret)
}
}

#[cfg(test)]
mod tests {
use bitwarden_core::Client;

use super::*;

fn sample_input() -> OpenOrgInvite {
OpenOrgInvite {
organization_id: "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".to_string(),
invite_link_code: "abcd1234efgh5678".to_string(),
invite_secret: "raw-invite-secret-material-base64url".to_string(),
}
}

#[test]
fn sealed_open_org_invite_json_wire_shape_is_stable() {
// Locks the JSON wire: two-key camelCase object, both values as strings.
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");

let json = serde_json::to_value(&sealed).expect("serialize");
let obj = json.as_object().expect("must be a JSON object");
assert_eq!(obj.len(), 2, "no extra or missing fields");
assert!(
obj.get("sealedData")
.expect("sealedData key must be present")
.is_string(),
"sealedData must serialize as a JSON string"
);
assert!(
obj.get("highEntropySecret")
.expect("highEntropySecret key must be present")
.is_string(),
"highEntropySecret must serialize as a JSON string"
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//! Version 1 of the open-org-invite plaintext payload β€” the innermost thing sealed by the
//! [`bitwarden_crypto::safe::DataEnvelope`]. Not to be confused with the sealed opaque blob
//! ([`super::SealedOpenOrgInviteData`]) or the outbound JSON ([`super::SealedOpenOrgInvite`]);
//! this file describes only the cleartext shape that gets CBOR-encoded and encrypted.
//!
//! Once this shape ships it cannot be broken: any field change means adding a new `V2` struct
//! and registering both variants on the versioned enum in [`super`], so old sealed payloads
//! still unseal.

use bitwarden_crypto::safe::SealableData;
use serde::{Deserialize, Serialize};

#[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_secret: String,
}

impl SealableData for RegistrationOpenOrgInviteDataV1 {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//! Open-organization-invite registration crossing.
//!
//! The app seals an invite context on registration-start submit and unseals it on the accept
//! open-org-invite component after a successful registration-finish. This module owns the
//! versioned plaintext payload (`data_v1`), the domain types and crypto operations
//! (`open_org_invite`), their wire encoding (`serialization`), and the FFI-facing client
//! methods (`client`).

mod client;
mod data_v1;
mod open_org_invite;
mod serialization;

use bitwarden_crypto::{
generate_versioned_sealable,
safe::{DataEnvelopeNamespace, SealableData, SealableVersionedData},
};
pub use client::SealedOpenOrgInvite;
use data_v1::RegistrationOpenOrgInviteDataV1;
pub use open_org_invite::{OpenOrgInvite, SealedOpenOrgInviteData};
use serde::{Deserialize, Serialize};
pub use serialization::SealedOpenOrgInviteDataError;

generate_versioned_sealable!(
RegistrationOpenOrgInviteData,
DataEnvelopeNamespace::RegistrationOpenOrgInviteData,
[RegistrationOpenOrgInviteDataV1 => "1"]
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//! `OpenOrgInvite` and its sealed form `SealedOpenOrgInviteData`, and the seal/unseal
//! operations between them. Seal returns the sealed blob paired with a `HighEntropySecret`.

use bitwarden_core::key_management::KeySlotIds;
use bitwarden_crypto::{
KeyStore,
safe::{
DataEnvelope, HighEntropySecret, SecretProtectedKeyEnvelope,
SecretProtectedKeyEnvelopeNamespace,
},
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "wasm")]
use tsify::Tsify;

use super::{RegistrationOpenOrgInviteData, data_v1::RegistrationOpenOrgInviteDataV1};
use crate::registration::registration_client::RegistrationError;

/// Byte length of the per-registration [`HighEntropySecret`] the seal path generates.
pub(super) const OPEN_ORG_INVITE_SECRET_SIZE_BYTES: usize = 32;

/// Plaintext open-organization-invite payload. Passed into
/// [`crate::registration::registration_client::RegistrationClient::seal_open_org_invite_data`] to
/// seal to be used in the registration email verification link, and returned by
/// [`crate::registration::registration_client::RegistrationClient::unseal_open_org_invite_data`]
/// for the acceptance flow.
#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct OpenOrgInvite {
/// The organization the registrant is joining.
pub organization_id: String,
/// The public invite link code carried in the shared invite URL.
pub invite_link_code: String,
/// The invite secret associated with the invite link.
pub invite_secret: String,
}

/// The two sealed envelopes that together carry an open-organization-invite payload.
#[derive(Debug, Clone)]
pub struct SealedOpenOrgInviteData {
pub(super) data_envelope: DataEnvelope,
pub(super) key_envelope: SecretProtectedKeyEnvelope,
}

impl SealedOpenOrgInviteData {
/// Seals an [`OpenOrgInvite`] into a [`SealedOpenOrgInviteData`] plus a freshly generated
/// [`HighEntropySecret`]. The caller must keep the secret client-side and place the sealed
/// data on the verification-email link; both halves are required to unseal.
pub fn seal(input: OpenOrgInvite) -> Result<(Self, HighEntropySecret), RegistrationError> {
// Per-call KeyStore β€” CEK never lives beyond this operation.
let key_store: KeyStore<KeySlotIds> = KeyStore::default();
let mut ctx = key_store.context_mut();

let high_entropy_secret = HighEntropySecret::make(OPEN_ORG_INVITE_SECRET_SIZE_BYTES)
.map_err(|_| RegistrationError::Crypto)?;

let versioned: RegistrationOpenOrgInviteData = RegistrationOpenOrgInviteDataV1 {
organization_id: input.organization_id,
invite_link_code: input.invite_link_code,
invite_secret: input.invite_secret,
}
.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)?;

Ok((
SealedOpenOrgInviteData {
data_envelope,
key_envelope,
},
high_entropy_secret,
))
}

/// Unseals a [`SealedOpenOrgInviteData`] back into an [`OpenOrgInvite`], given the paired
/// [`HighEntropySecret`] returned by [`Self::seal`]. Returns [`RegistrationError::Crypto`]
/// if the secret does not match the sealed payload or the payload has been tampered with.
pub fn unseal(&self, secret: &HighEntropySecret) -> Result<OpenOrgInvite, RegistrationError> {
// Per-call KeyStore β€” CEK never lives beyond this function.
let key_store: KeyStore<KeySlotIds> = KeyStore::default();
let mut ctx = key_store.context_mut();

let cek_id = self
.key_envelope
.unseal(
secret,
SecretProtectedKeyEnvelopeNamespace::RegistrationOpenOrgInvite,
&mut ctx,
)
.map_err(|_| RegistrationError::Crypto)?;

let versioned: RegistrationOpenOrgInviteData = self
.data_envelope
.unseal(cek_id, &mut ctx)
.map_err(|_| RegistrationError::Crypto)?;

// No post-decrypt equality check on the plaintext β€” the AES-GCM auth tag at each
// envelope layer is the substitution defense.
let RegistrationOpenOrgInviteData::RegistrationOpenOrgInviteDataV1(v1) = versioned;
Ok(OpenOrgInvite {
organization_id: v1.organization_id,
invite_link_code: v1.invite_link_code,
invite_secret: v1.invite_secret,
})
}
}

#[cfg(test)]
mod tests {
use super::*;

fn sample_input() -> OpenOrgInvite {
OpenOrgInvite {
organization_id: "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".to_string(),
invite_link_code: "abcd1234efgh5678".to_string(),
invite_secret: "raw-invite-secret-material-base64url".to_string(),
}
}

#[test]
fn seal_produces_populated_sealed_data_and_high_entropy_secret() {
let (sealed_data, high_entropy_secret) =
SealedOpenOrgInviteData::seal(sample_input()).expect("seal should succeed");

let wire = String::from(&sealed_data);
assert!(!wire.is_empty());
let parsed: SealedOpenOrgInviteData = wire.parse().expect("wire form must round-trip");
let _ = parsed.data_envelope;
let _ = parsed.key_envelope;

// High-entropy secret should also round-trip via its own wire form.
let secret_wire = String::from(high_entropy_secret);
assert!(!secret_wire.is_empty());
secret_wire
.parse::<HighEntropySecret>()
.expect("high_entropy_secret must be a valid wire string");
}

#[test]
fn two_seals_produce_distinct_secrets_and_data() {
let (first_data, first_secret) =
SealedOpenOrgInviteData::seal(sample_input()).expect("first seal should succeed");
let (second_data, second_secret) =
SealedOpenOrgInviteData::seal(sample_input()).expect("second seal should succeed");

// Per-registration randomness: fresh CEK + secret + HKDF salt.
assert_ne!(String::from(first_secret), String::from(second_secret));
assert_ne!(String::from(&first_data), String::from(&second_data));
}

#[test]
fn seal_unseal_round_trip_recovers_original_fields() {
let input = sample_input();
let (sealed_data, high_entropy_secret) =
SealedOpenOrgInviteData::seal(input.clone()).expect("seal should succeed");

let unsealed = sealed_data
.unseal(&high_entropy_secret)
.expect("unseal should succeed");

assert_eq!(unsealed, input);
}

#[test]
fn unseal_fails_with_wrong_high_entropy_secret() {
let (sealed_data, _) =
SealedOpenOrgInviteData::seal(sample_input()).expect("seal should succeed");
let unrelated = HighEntropySecret::make(OPEN_ORG_INVITE_SECRET_SIZE_BYTES).unwrap();

let err = sealed_data
.unseal(&unrelated)
.expect_err("unseal must reject an unrelated secret");
assert!(matches!(err, RegistrationError::Crypto));
}
}
Loading
Loading