diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a5a73ef9b..69740e277 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/Cargo.lock b/Cargo.lock index c48d08180..ff171acb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,6 +559,7 @@ dependencies = [ "bitwarden-state", "bitwarden-test", "chrono", + "ciborium", "http", "reqwest", "reqwest-middleware", diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index 1b005763c..06983adc2 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -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] @@ -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 } diff --git a/crates/bitwarden-auth/src/registration/mod.rs b/crates/bitwarden-auth/src/registration/mod.rs index 54ab069a0..e58a7c2fc 100644 --- a/crates/bitwarden-auth/src/registration/mod.rs +++ b/crates/bitwarden-auth/src/registration/mod.rs @@ -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, }; diff --git a/crates/bitwarden-auth/src/registration/open_org_invite_crypto/client.rs b/crates/bitwarden-auth/src/registration/open_org_invite_crypto/client.rs new file mode 100644 index 000000000..d869ddc7e --- /dev/null +++ b/crates/bitwarden-auth/src/registration/open_org_invite_crypto/client.rs @@ -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 { + 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 { + 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" + ); + } +} diff --git a/crates/bitwarden-auth/src/registration/open_org_invite_crypto/data_v1.rs b/crates/bitwarden-auth/src/registration/open_org_invite_crypto/data_v1.rs new file mode 100644 index 000000000..1f7eea4bc --- /dev/null +++ b/crates/bitwarden-auth/src/registration/open_org_invite_crypto/data_v1.rs @@ -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 {} diff --git a/crates/bitwarden-auth/src/registration/open_org_invite_crypto/mod.rs b/crates/bitwarden-auth/src/registration/open_org_invite_crypto/mod.rs new file mode 100644 index 000000000..c3001575e --- /dev/null +++ b/crates/bitwarden-auth/src/registration/open_org_invite_crypto/mod.rs @@ -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"] +); diff --git a/crates/bitwarden-auth/src/registration/open_org_invite_crypto/open_org_invite.rs b/crates/bitwarden-auth/src/registration/open_org_invite_crypto/open_org_invite.rs new file mode 100644 index 000000000..6928eda71 --- /dev/null +++ b/crates/bitwarden-auth/src/registration/open_org_invite_crypto/open_org_invite.rs @@ -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 = 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 { + // Per-call KeyStore — CEK never lives beyond this function. + let key_store: KeyStore = 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::() + .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)); + } +} diff --git a/crates/bitwarden-auth/src/registration/open_org_invite_crypto/serialization.rs b/crates/bitwarden-auth/src/registration/open_org_invite_crypto/serialization.rs new file mode 100644 index 000000000..b52cac679 --- /dev/null +++ b/crates/bitwarden-auth/src/registration/open_org_invite_crypto/serialization.rs @@ -0,0 +1,205 @@ +//! Wire encoding for [`SealedOpenOrgInviteData`]. Its two internal envelopes are packed with +//! CBOR (compact binary format) then base64url-wrapped so it crosses every boundary as one +//! opaque string — via serde on the Rust side and the WASM ABI (wasm-bindgen's Rust↔JS +//! conversion layer) into TypeScript. + +use std::str::FromStr; + +use bitwarden_crypto::safe::{DataEnvelope, SecretProtectedKeyEnvelope}; +use bitwarden_encoding::{B64Url, FromStrVisitor}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::SealedOpenOrgInviteData; + +/// Intermediate shape used to (de)serialize [`SealedOpenOrgInviteData`] to and from its wire +/// bytes. Each envelope is carried as raw bytes under a short field name for compactness. +#[derive(Serialize, Deserialize)] +struct SealedOpenOrgInviteDataWire { + // Without serde_bytes, Vec encodes as a CBOR array of integers (~2x the size). + #[serde(rename = "d", with = "serde_bytes")] + data_envelope: Vec, + #[serde(rename = "k", with = "serde_bytes")] + key_envelope: Vec, +} + +/// Errors returned when parsing a [`SealedOpenOrgInviteData`] from its wire form. +#[derive(Debug, Error)] +pub enum SealedOpenOrgInviteDataError { + /// The wire string could not be decoded. + #[error("Sealed open org invite data is malformed")] + Malformed, +} + +impl FromStr for SealedOpenOrgInviteData { + type Err = SealedOpenOrgInviteDataError; + + fn from_str(s: &str) -> Result { + let outer = B64Url::try_from(s).map_err(|_| SealedOpenOrgInviteDataError::Malformed)?; + let wire: SealedOpenOrgInviteDataWire = ciborium::de::from_reader(outer.as_bytes()) + .map_err(|_| SealedOpenOrgInviteDataError::Malformed)?; + let data_envelope = DataEnvelope::from(wire.data_envelope); + let key_envelope = SecretProtectedKeyEnvelope::try_from(&wire.key_envelope) + .map_err(|_| SealedOpenOrgInviteDataError::Malformed)?; + Ok(SealedOpenOrgInviteData { + data_envelope, + key_envelope, + }) + } +} + +impl From<&SealedOpenOrgInviteData> for String { + fn from(val: &SealedOpenOrgInviteData) -> Self { + let data_bytes: Vec = (&val.data_envelope).into(); + let key_bytes: Vec = (&val.key_envelope).into(); + let wire = SealedOpenOrgInviteDataWire { + data_envelope: data_bytes, + key_envelope: key_bytes, + }; + let mut buf = Vec::new(); + ciborium::ser::into_writer(&wire, &mut buf) + .expect("CBOR encoding of two byte fields cannot fail"); + B64Url::from(buf).to_string() + } +} + +impl From for String { + fn from(val: SealedOpenOrgInviteData) -> Self { + (&val).into() + } +} + +impl Serialize for SealedOpenOrgInviteData { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&String::from(self)) + } +} + +impl<'de> Deserialize<'de> for SealedOpenOrgInviteData { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_str(FromStrVisitor::new()) + } +} + +// WASM ABI: `SealedOpenOrgInviteData` marshals as its wire string, matching the JSON wire form. +#[cfg(feature = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(typescript_custom_section)] +const TS_CUSTOM_TYPES: &'static str = r#" +export type SealedOpenOrgInviteData = Tagged; +"#; + +#[cfg(feature = "wasm")] +impl wasm_bindgen::describe::WasmDescribe for SealedOpenOrgInviteData { + fn describe() { + ::describe(); + } +} + +#[cfg(feature = "wasm")] +impl wasm_bindgen::convert::FromWasmAbi for SealedOpenOrgInviteData { + type Abi = ::Abi; + + unsafe fn from_abi(abi: Self::Abi) -> Self { + use wasm_bindgen::UnwrapThrowExt; + let string = unsafe { String::from_abi(abi) }; + SealedOpenOrgInviteData::from_str(&string).unwrap_throw() + } +} + +#[cfg(feature = "wasm")] +impl wasm_bindgen::convert::OptionFromWasmAbi for SealedOpenOrgInviteData { + fn is_none(abi: &Self::Abi) -> bool { + ::is_none(abi) + } +} + +#[cfg(feature = "wasm")] +impl wasm_bindgen::convert::IntoWasmAbi for SealedOpenOrgInviteData { + type Abi = ::Abi; + + fn into_abi(self) -> Self::Abi { + String::from(self).into_abi() + } +} + +#[cfg(test)] +mod tests { + use bitwarden_encoding::B64Url; + + use super::*; + use crate::registration::open_org_invite_crypto::{OpenOrgInvite, SealedOpenOrgInviteData}; + + 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_data_wire_is_valid_base64url() { + let (sealed_data, _) = + SealedOpenOrgInviteData::seal(sample_input()).expect("seal should succeed"); + + let wire = String::from(&sealed_data); + let decoded = + B64Url::try_from(wire.as_str()).expect("sealed_data wire must be valid base64url"); + assert_eq!(B64Url::from(decoded.as_bytes()).to_string(), wire); + } + + #[test] + fn parse_rejects_truncated_wire() { + let (sealed_data, _) = + SealedOpenOrgInviteData::seal(sample_input()).expect("seal should succeed"); + + let mut wire = String::from(&sealed_data); + wire.truncate(wire.len() / 2); + + let err = wire + .parse::() + .expect_err("truncated wire must be rejected at parse time"); + assert!(matches!(err, SealedOpenOrgInviteDataError::Malformed)); + } + + #[test] + fn parse_rejects_malformed_base64url() { + let err = "not-valid-base64url!" + .parse::() + .expect_err("malformed base64url must be rejected at parse time"); + assert!(matches!(err, SealedOpenOrgInviteDataError::Malformed)); + + assert!(B64Url::try_from("not-valid-base64url!").is_err()); + } + + #[test] + fn parse_rejects_valid_cbor_with_bad_key_envelope_bytes() { + // Well-formed base64url + CBOR wire whose `k` field bytes don't parse as a + // SecretProtectedKeyEnvelope — exercises the parse-envelope failure path. + #[derive(serde::Serialize)] + struct FakeWire<'a> { + #[serde(rename = "d", with = "serde_bytes")] + d: &'a [u8], + #[serde(rename = "k", with = "serde_bytes")] + k: &'a [u8], + } + let fake = FakeWire { + d: &[1, 2, 3, 4], // DataEnvelope::from is an infallible byte-wrap; that's fine + k: &[0xff, 0xff, 0xff], // won't parse as SecretProtectedKeyEnvelope + }; + let mut buf = Vec::new(); + ciborium::ser::into_writer(&fake, &mut buf).unwrap(); + let wire = B64Url::from(buf).to_string(); + + let err = wire + .parse::() + .expect_err("bad key-envelope bytes must be rejected at parse time"); + assert!(matches!(err, SealedOpenOrgInviteDataError::Malformed)); + } +} diff --git a/crates/bitwarden-auth/tests/registration_open_org_invite.rs b/crates/bitwarden-auth/tests/registration_open_org_invite.rs new file mode 100644 index 000000000..e39f10af7 --- /dev/null +++ b/crates/bitwarden-auth/tests/registration_open_org_invite.rs @@ -0,0 +1,97 @@ +//! Integration tests for the open-org-invite registration crossing — public API only. + +use bitwarden_auth::{ + AuthClientExt, + registration::{OpenOrgInvite, RegistrationError, SealedOpenOrgInvite}, +}; +use bitwarden_core::Client; +use bitwarden_crypto::safe::HighEntropySecret; + +/// Pinned JSON wire vector: a `SealedOpenOrgInvite` produced by sealing [`sample_input`] with a +/// specific paired `HighEntropySecret`. Guards backward compatibility of the wire format — a +/// break here means the on-disk / on-URL form of sealed data has changed, which would break any +/// sealed URL already in flight. +/// +/// Regenerate manually only if the format is being intentionally rev'd (add a new pinned vector +/// alongside; don't replace this one). +const TEST_VECTOR_SEALED_JSON: &str = "{\"sealedData\":\"omFkWQEIg1hHpQEDA3gjYXBwbGljYXRpb24veC5iaXR3YXJkZW4uY2Jvci1wYWRkZWQEUHP2JYxlt7tDr1P2p4K_W446AAE4gQI6AAE4gAOhBUz0QLrresgFvPHv45VYrcsnCWe2bRkIQIJYFd--cqcLibGDixkdYrJJkYauKwJdaUcq5yK_xrruwJajT6s7UBhtDczVdrEWhtcshb6p_9PoGoKpb9ffUdyqbLumOFGJvUoMcDi9-welLjJ_AX4Qu16ITBDs2Q5KiN3iuVU9N9JEYjgyRHxccpoVuIc3yPFyDIsYcQ0KFmBRpDw50KhYD-G4Tb19QaMfaHEWK83OMIDvmea2FSQOU1SpUbvJYWtYs4RYKKUBAwMYZToAARVcUHP2JYxlt7tDr1P2p4K_W446AAE4gQY6AAE4gAOhBUy6jePlDBaQIzt_vDdYTQiINL9v4KZ077Nf657IAAH7FsyuG9JiCQrID904q7yJGcJYJEYAu9CvKz2ts-oVAjOUu3evNyxqCU2zM2tlwaGqjSET1A1WACmazG3KgYNAogEpM1gg8Ayj9aREd1n-rEPoj97Crrm6DZ1nADBMjLJeAO32VAz2\",\"highEntropySecret\":\"/oeYS4R7k3lWzqtMkydiZlWWK0M5Nkj3qU1I0/k/1YU=\"}"; + +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_unseal_round_trip_via_public_api() { + let client = Client::new(None); + let registration = client.auth_new().registration(); + + let input = sample_input(); + let sealed = registration + .seal_open_org_invite_data(input.clone()) + .expect("seal should succeed"); + + let unsealed = registration + .unseal_open_org_invite_data(sealed) + .expect("unseal should succeed"); + + assert_eq!(unsealed, input); +} + +#[test] +fn pinned_wire_vector_unseals_to_expected_plaintext() { + // Wire format lock-in: deserializing the pinned JSON vector via the public API must always + // produce the exact plaintext it was sealed from. If this fails, the on-disk/on-URL form + // has changed and existing sealed URLs would no longer unseal. + let client = Client::new(None); + let registration = client.auth_new().registration(); + + let sealed: SealedOpenOrgInvite = + serde_json::from_str(TEST_VECTOR_SEALED_JSON).expect("pinned vector must deserialize"); + let unsealed = registration + .unseal_open_org_invite_data(sealed) + .expect("pinned vector must unseal"); + + assert_eq!(unsealed, sample_input()); +} + +#[test] +fn unseal_with_wrong_secret_returns_crypto_error_via_public_api() { + let client = Client::new(None); + let registration = client.auth_new().registration(); + + let mut sealed = registration + .seal_open_org_invite_data(sample_input()) + .expect("seal should succeed"); + sealed.high_entropy_secret = HighEntropySecret::make(32).expect("fresh secret"); + + let err = registration + .unseal_open_org_invite_data(sealed) + .expect_err("unseal must reject a mismatched secret"); + assert!(matches!(err, RegistrationError::Crypto)); +} + +#[test] +fn seal_json_round_trip_unseal_via_public_api() { + // Mirrors the production path: the client seals, ships the JSON through the server + URL, + // and Tab B deserializes back to `SealedOpenOrgInvite` before unsealing. + let client = Client::new(None); + let registration = client.auth_new().registration(); + + let input = sample_input(); + let sealed = registration + .seal_open_org_invite_data(input.clone()) + .expect("seal should succeed"); + + let wire_json = serde_json::to_string(&sealed).expect("serialize"); + let round_tripped: SealedOpenOrgInvite = serde_json::from_str(&wire_json).expect("deserialize"); + + let unsealed = registration + .unseal_open_org_invite_data(round_tripped) + .expect("unseal after JSON round-trip should succeed"); + + assert_eq!(unsealed, input); +} diff --git a/crates/bitwarden-crypto/src/safe/data_envelope.rs b/crates/bitwarden-crypto/src/safe/data_envelope.rs index 8d7bf6a0e..021968524 100644 --- a/crates/bitwarden-crypto/src/safe/data_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/data_envelope.rs @@ -499,6 +499,10 @@ pub enum DataEnvelopeNamespace { /// The namespace for organization member invite data (the organization public-key thumbprint /// and the invite secret), sealed with the invite key. OrganizationInvite = 2, + /// Namespace for the invite context payload sealed on registration-start and unsealed on + /// registration-finish so an anonymous, email-verified new user can complete an open + /// organization invite in a single flow. + RegistrationOpenOrgInviteData = 3, /// This namespace is only used in tests #[cfg(test)] ExampleNamespace = -1, @@ -521,6 +525,7 @@ impl TryFrom for DataEnvelopeNamespace { match value { 1 => Ok(DataEnvelopeNamespace::VaultItem), 2 => Ok(DataEnvelopeNamespace::OrganizationInvite), + 3 => Ok(DataEnvelopeNamespace::RegistrationOpenOrgInviteData), #[cfg(test)] -1 => Ok(DataEnvelopeNamespace::ExampleNamespace), #[cfg(test)] @@ -786,6 +791,51 @@ mod tests { assert!(matches!(result, Err(DataEnvelopeError::InvalidNamespace))); } + #[test] + fn test_registration_open_org_invite_namespace_maps_to_expected_discriminant() { + // The wire discriminant is load-bearing once shipped; a regression here would silently + // invalidate every envelope sealed by production callers. + assert_eq!( + DataEnvelopeNamespace::try_from(3i128).unwrap(), + DataEnvelopeNamespace::RegistrationOpenOrgInviteData + ); + assert_eq!( + i128::from(DataEnvelopeNamespace::RegistrationOpenOrgInviteData), + 3 + ); + } + + #[test] + fn test_registration_open_org_invite_data_rejects_cross_namespace_unseal() { + // AC 1.iii analogue: an envelope sealed under the new production namespace must not + // unseal under any other `DataEnvelopeNamespace` (here, the sibling production + // `VaultItem`). Mirrors `test_namespace_cross_contamination_protection` for the new + // variant. + let data: TestData = TestDataV1 { field: 42 }.into(); + + let cek = Aes256GcmKey::make(); + let envelope = DataEnvelope::seal_ref( + &data, + DataEnvelopeNamespace::RegistrationOpenOrgInviteData, + &cek, + ) + .unwrap(); + + assert!(matches!( + unseal_with_cek::(&envelope, DataEnvelopeNamespace::VaultItem, &cek), + Err(DataEnvelopeError::InvalidNamespace) + )); + + // Correct namespace still succeeds. + let round_tripped: TestData = unseal_with_cek( + &envelope, + DataEnvelopeNamespace::RegistrationOpenOrgInviteData, + &cek, + ) + .unwrap(); + assert_eq!(round_tripped, data); + } + #[test] fn test_namespace_cross_contamination_protection() { let data1: TestData = TestDataV1 { field: 111 }.into(); diff --git a/crates/bitwarden-crypto/src/safe/high_entropy_secret.rs b/crates/bitwarden-crypto/src/safe/high_entropy_secret.rs index dd7ef7047..9d786c2db 100644 --- a/crates/bitwarden-crypto/src/safe/high_entropy_secret.rs +++ b/crates/bitwarden-crypto/src/safe/high_entropy_secret.rs @@ -6,10 +6,12 @@ //! bytes. They are unlike low-entropy secrets such as PINs or passwords, which can be brute-forced //! and therefore require a memory- or compute-hard KDF. -#[cfg(feature = "wasm")] -use bitwarden_encoding::B64; +use std::str::FromStr; + +use bitwarden_encoding::{B64, FromStrVisitor}; use bitwarden_sensitive_value::{ExposeSensitive, Sensitive, SensitiveSlice}; use rand::Rng; +use serde::{Deserialize, Serialize}; use thiserror::Error; #[cfg(feature = "wasm")] use wasm_bindgen::convert::{FromWasmAbi, IntoWasmAbi, OptionFromWasmAbi}; @@ -83,6 +85,42 @@ impl HighEntropySecret { } } +impl FromStr for HighEntropySecret { + type Err = HighEntropySecretError; + + fn from_str(s: &str) -> Result { + let bytes = B64::try_from(s).map_err(|_| HighEntropySecretError::Malformed)?; + if bytes.as_bytes().len() < MIN_SECRET_LENGTH { + return Err(HighEntropySecretError::TooShort); + } + Ok(Self::from_internal(bytes.as_bytes())) + } +} + +impl From for String { + fn from(val: HighEntropySecret) -> Self { + B64::from(val.secret.as_slice()).to_string() + } +} + +impl<'de> Deserialize<'de> for HighEntropySecret { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_str(FromStrVisitor::new()) + } +} + +impl Serialize for HighEntropySecret { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&B64::from(self.secret.as_slice()).to_string()) + } +} + // Manually implemented so the secret material is never printed. impl std::fmt::Debug for HighEntropySecret { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -96,12 +134,15 @@ pub enum HighEntropySecretError { /// The provided secret is too short to be used as a high-entropy secret. #[error("Secret is too short")] TooShort, + /// The provided string could not be decoded as standardized base64. + #[error("Secret is not valid base64")] + Malformed, } #[cfg(feature = "wasm")] #[wasm_bindgen::prelude::wasm_bindgen(typescript_custom_section)] const TS_CUSTOM_TYPES: &'static str = r#" -export type HighEntropySecret = Tagged; +export type HighEntropySecret = Tagged; "#; #[cfg(feature = "wasm")] @@ -118,8 +159,7 @@ impl FromWasmAbi for HighEntropySecret { unsafe fn from_abi(abi: Self::Abi) -> Self { use wasm_bindgen::UnwrapThrowExt; let string = unsafe { String::from_abi(abi) }; - let b64 = B64::try_from(string).unwrap_throw(); - HighEntropySecret::from_internal(b64.as_bytes()) + HighEntropySecret::from_str(&string).unwrap_throw() } } @@ -213,4 +253,59 @@ mod tests { cloned.as_bytes().expose_owned() ); } + + #[test] + fn test_base64_round_trip_preserves_bytes() { + let secret = HighEntropySecret::make(32).unwrap(); + let bytes = secret.as_bytes().expose_owned().to_vec(); + let encoded = String::from(secret); + let decoded: HighEntropySecret = encoded.parse().unwrap(); + assert_eq!(decoded.as_bytes().expose_owned(), bytes.as_slice()); + } + + #[test] + fn test_from_str_rejects_malformed_input() { + assert!(matches!( + "!!!not-base64!!!".parse::(), + Err(HighEntropySecretError::Malformed) + )); + } + + #[test] + fn test_from_str_rejects_below_minimum_length() { + // Any successfully-decoded input shorter than MIN_SECRET_LENGTH bytes must be rejected + // so the type's length floor holds regardless of the constructor used. + for byte_len in 0..MIN_SECRET_LENGTH { + let bytes = vec![0u8; byte_len]; + let encoded = B64::from(bytes.as_slice()).to_string(); + assert!( + matches!( + encoded.parse::(), + Err(HighEntropySecretError::TooShort) + ), + "expected decoded length {byte_len} to be rejected as too short" + ); + } + } + + #[test] + fn test_from_str_accepts_input_at_minimum_length() { + let bytes = vec![7u8; MIN_SECRET_LENGTH]; + let encoded = B64::from(bytes.as_slice()).to_string(); + let secret: HighEntropySecret = encoded + .parse() + .expect("input at minimum length is accepted"); + assert_eq!(secret.as_bytes().expose_owned().len(), MIN_SECRET_LENGTH); + } + + #[test] + fn test_serde_json_round_trip_preserves_bytes() { + // Locks the serde impls directly. Without this, a regression in `Serialize` / + // `Deserialize` would only surface via a downstream crate's integration test. + let secret = HighEntropySecret::make(32).unwrap(); + let bytes = secret.as_bytes().expose_owned().to_vec(); + let json = serde_json::to_string(&secret).expect("serialize"); + let round_tripped: HighEntropySecret = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(round_tripped.as_bytes().expose_owned(), bytes.as_slice()); + } } diff --git a/crates/bitwarden-crypto/src/safe/secret_protected_key_envelope.rs b/crates/bitwarden-crypto/src/safe/secret_protected_key_envelope.rs index 07f0c13ee..dd98f76cf 100644 --- a/crates/bitwarden-crypto/src/safe/secret_protected_key_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/secret_protected_key_envelope.rs @@ -565,6 +565,9 @@ pub enum SecretProtectedKeyEnvelopeNamespace { /// Bitwarden Desktop biometric (Windows Hello) unlock. The high-entropy secret is a PRF derived /// from the Windows Hello signing credential, and the sealed key is the user key. DesktopBiometricUnlock = 2, + /// Used for threading the open-organization-invite data through the registration with email + /// verification process. Is generated per registration email. + RegistrationOpenOrgInvite = 3, /// This namespace is only used in tests #[cfg(test)] ExampleNamespace = -1, @@ -587,6 +590,7 @@ impl TryFrom for SecretProtectedKeyEnvelopeNamespace { match value { 1 => Ok(SecretProtectedKeyEnvelopeNamespace::OrganizationInvite), 2 => Ok(SecretProtectedKeyEnvelopeNamespace::DesktopBiometricUnlock), + 3 => Ok(SecretProtectedKeyEnvelopeNamespace::RegistrationOpenOrgInvite), #[cfg(test)] -1 => Ok(SecretProtectedKeyEnvelopeNamespace::ExampleNamespace), #[cfg(test)] @@ -668,6 +672,58 @@ mod tests { 149, 151, 217, 94, 84, 67, 50, 107, 103, 74, 88, 72, 246, ]; + #[test] + fn test_registration_open_org_invite_namespace_maps_to_expected_discriminant() { + // The wire discriminant is load-bearing once shipped; a regression here would silently + // invalidate every envelope sealed by production callers. + assert_eq!( + SecretProtectedKeyEnvelopeNamespace::try_from(3i128).unwrap(), + SecretProtectedKeyEnvelopeNamespace::RegistrationOpenOrgInvite + ); + assert_eq!( + i128::from(SecretProtectedKeyEnvelopeNamespace::RegistrationOpenOrgInvite), + 3 + ); + } + + #[test] + fn test_registration_open_org_invite_rejects_cross_namespace_unseal() { + // An envelope sealed under the new production namespace must not unseal under any + // other production namespace (here, the sibling production + // `DesktopBiometricUnlock`). Mirrors the sibling test in `data_envelope.rs` for the + // new variant. + let key_store = KeyStore::::default(); + let mut ctx: KeyStoreContext<'_, TestIds> = key_store.context_mut(); + let test_key = ctx.make_symmetric_key(SymmetricKeyAlgorithm::XChaCha20Poly1305); + let secret = testvector_secret(); + + let envelope = SecretProtectedKeyEnvelope::seal( + test_key, + &secret, + SecretProtectedKeyEnvelopeNamespace::RegistrationOpenOrgInvite, + &ctx, + ) + .expect("seal"); + + assert!(matches!( + envelope.unseal( + &secret, + SecretProtectedKeyEnvelopeNamespace::DesktopBiometricUnlock, + &mut ctx, + ), + Err(SecretProtectedKeyEnvelopeError::InvalidNamespace) + )); + + // Correct namespace still succeeds. + let _ = envelope + .unseal( + &secret, + SecretProtectedKeyEnvelopeNamespace::RegistrationOpenOrgInvite, + &mut ctx, + ) + .expect("unseal under correct namespace succeeds"); + } + #[test] #[ignore = "Manual test to verify debug format"] fn test_debug() { diff --git a/crates/bitwarden-organization-crypto/Cargo.toml b/crates/bitwarden-organization-crypto/Cargo.toml index 60bd72384..13fe8c9c3 100644 --- a/crates/bitwarden-organization-crypto/Cargo.toml +++ b/crates/bitwarden-organization-crypto/Cargo.toml @@ -22,7 +22,7 @@ wasm = [ "bitwarden-crypto/wasm", "bitwarden-encoding/wasm", "bitwarden-random/wasm", -] # WASM support +] [dependencies] bitwarden-crypto = { workspace = true } diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/registration/open-org-invite-registration.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/registration/open-org-invite-registration.test.ts new file mode 100644 index 000000000..0192133ae --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/registration/open-org-invite-registration.test.ts @@ -0,0 +1,54 @@ +import { makePasswordManagerClient, makeStateBridge } from "../utils"; + +const SAMPLE_INPUT = { + organizationId: "1bc9ac1e-f5aa-45f2-94bf-b181009709b8", + inviteLinkCode: "abcd1234efgh5678", + inviteSecret: "raw-invite-secret-material-base64url", +}; + +describe("open org invite registration seal/unseal", () => { + it("seal_open_org_invite_data returns a non-empty sealedData and paired highEntropySecret", async () => { + const client = makePasswordManagerClient(makeStateBridge()); + + const sealed = client.auth().registration().seal_open_org_invite_data(SAMPLE_INPUT); + + expect(sealed.sealedData).not.toEqual(""); + expect(sealed.highEntropySecret).not.toEqual(""); + }); + + it("unseal_open_org_invite_data recovers the plaintext invite context with fields intact", () => { + const client = makePasswordManagerClient(makeStateBridge()); + const registration = client.auth().registration(); + + const sealed = registration.seal_open_org_invite_data(SAMPLE_INPUT); + const unsealed = registration.unseal_open_org_invite_data(sealed); + + expect(unsealed.organizationId).toEqual(SAMPLE_INPUT.organizationId); + expect(unsealed.inviteLinkCode).toEqual(SAMPLE_INPUT.inviteLinkCode); + expect(unsealed.inviteSecret).toEqual(SAMPLE_INPUT.inviteSecret); + }); + + it("two independent seals produce different highEntropySecret values (per-registration randomness)", () => { + const client = makePasswordManagerClient(makeStateBridge()); + const registration = client.auth().registration(); + + const first = registration.seal_open_org_invite_data(SAMPLE_INPUT); + const second = registration.seal_open_org_invite_data(SAMPLE_INPUT); + + expect(first.highEntropySecret).not.toEqual(second.highEntropySecret); + expect(first.sealedData).not.toEqual(second.sealedData); + }); + + it("the sealedData serializes as base64url that crosses the FFI boundary intact", async () => { + const client = makePasswordManagerClient(makeStateBridge()); + + const sealed = client.auth().registration().seal_open_org_invite_data(SAMPLE_INPUT); + + // Wire-format sanity: sealedData must round-trip through Node's native "base64url" + // encoding (available since Node 16) without drift. + const sealedStr = sealed.sealedData as unknown as string; + expect(sealedStr).not.toEqual(""); + const reencoded = Buffer.from(sealedStr, "base64url").toString("base64url"); + expect(reencoded).toEqual(sealedStr); + }); +});