Compare commits

..
41 changed files with 1832 additions and 323 deletions

No files matched your search

+10
View File
@@ -396,6 +396,7 @@
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled! ## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
## ##
## The following flags are available: ## The following flags are available:
## - "fill-assist-targeting-rules": Enable Fill Assist targeting rules in the browser extension. (Browser >= 2026.6.0)
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0) ## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0) ## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) ## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
@@ -496,6 +497,15 @@
## KNOW WHAT YOU ARE DOING! ## KNOW WHAT YOU ARE DOING!
# ORG_GROUPS_ENABLED=false # ORG_GROUPS_ENABLED=false
## EXPERIMENTAL FEATURE: Custom roles
## Enables the Custom member role with granular permissions for organizations.
## This is still being developed: only the permissions this version enforces can be granted.
## This is enforced by the server and is unrelated to EXPERIMENTAL_CLIENT_FEATURE_FLAGS.
## Disabling it again keeps the stored Custom roles and permissions, but they grant nothing until it is re-enabled.
## Disabled by default because this is an experimental feature!
## KNOW WHAT YOU ARE DOING!
# EXPERIMENTAL_CUSTOM_ROLES=false
## Increase secure note size limit (Know the risks!) ## Increase secure note size limit (Know the risks!)
## Sets the secure note size limit to 100_000 instead of the default 10_000. ## Sets the secure note size limit to 100_000 instead of the default 10_000.
## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! ## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
+2 -3
View File
@@ -14,9 +14,8 @@ extend-ignore-re = [
# In SMTP it's called HELO, so ignore it # In SMTP it's called HELO, so ignore it
"(?i)helo_name", "(?i)helo_name",
"Server name sent during.+HELO", "Server name sent during.+HELO",
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items # COSE Is short for CBOR Object Signing and Encryption
"COSEKey", "(?i)cose",
"COSEAlgorithm",
# Ignore this specific string as it's valid # Ignore this specific string as it's valid
"Ensure they are valid OTPs", "Ensure they are valid OTPs",
# This word is misspelled upstream # This word is misspelled upstream
+1 -1
View File
@@ -107,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154" serde_json = "1.0.154"
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric"] } diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
diesel_migrations = "2.3.2" diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [ derive_more = { version = "2.1.1", features = [
Whitespace-only changes.
@@ -0,0 +1,3 @@
-- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the
-- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT.
ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL;
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,14 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE
);
@@ -0,0 +1,22 @@
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
-- versions before this one don't know. So while there are any, this fails on the duplicate key below, before anything
-- is dropped: change their role first, or restore the backup taken before the upgrade.
-- A failed attempt leaves the temporary table on this connection. Clear it before retrying.
DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade;
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
INSERT INTO custom_role_downgrade
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
FROM users_organizations WHERE atype = 4 LIMIT 1;
DROP TEMPORARY TABLE custom_role_downgrade;
ALTER TABLE users_organizations
DROP COLUMN manage_users,
DROP COLUMN manage_groups,
DROP COLUMN manage_policies,
DROP COLUMN create_new_collections,
DROP COLUMN edit_any_collection,
DROP COLUMN delete_any_collection,
DROP COLUMN access_event_logs,
DROP COLUMN access_import_export,
DROP COLUMN access_reports;
@@ -0,0 +1,13 @@
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
ALTER TABLE users_organizations
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
Whitespace-only changes.
@@ -0,0 +1,3 @@
-- TEXT has no size limit here, only MySQL needed the change.
-- PostgreSQL rejects a migration that is only comments ("Received an empty query"), hence the no-op.
SELECT 1;
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
@@ -0,0 +1,20 @@
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
INSERT INTO custom_role_downgrade
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
FROM users_organizations WHERE atype = 4 LIMIT 1;
DROP TABLE custom_role_downgrade;
ALTER TABLE users_organizations
DROP COLUMN manage_users,
DROP COLUMN manage_groups,
DROP COLUMN manage_policies,
DROP COLUMN create_new_collections,
DROP COLUMN edit_any_collection,
DROP COLUMN delete_any_collection,
DROP COLUMN access_event_logs,
DROP COLUMN access_import_export,
DROP COLUMN access_reports;
@@ -0,0 +1,13 @@
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
ALTER TABLE users_organizations
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
Whitespace-only changes.
@@ -0,0 +1 @@
-- TEXT has no size limit here, only MySQL needed the change
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
@@ -0,0 +1,19 @@
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
INSERT INTO custom_role_downgrade
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
FROM users_organizations WHERE atype = 4 LIMIT 1;
DROP TABLE custom_role_downgrade;
ALTER TABLE users_organizations DROP COLUMN manage_users;
ALTER TABLE users_organizations DROP COLUMN manage_groups;
ALTER TABLE users_organizations DROP COLUMN manage_policies;
ALTER TABLE users_organizations DROP COLUMN create_new_collections;
ALTER TABLE users_organizations DROP COLUMN edit_any_collection;
ALTER TABLE users_organizations DROP COLUMN delete_any_collection;
ALTER TABLE users_organizations DROP COLUMN access_event_logs;
ALTER TABLE users_organizations DROP COLUMN access_import_export;
ALTER TABLE users_organizations DROP COLUMN access_reports;
@@ -0,0 +1,11 @@
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT 0; -- FALSE
+32 -6
View File
@@ -1,4 +1,4 @@
use std::{env, sync::LazyLock}; use std::{collections::HashMap, env, sync::LazyLock};
use reqwest::Method; use reqwest::Method;
use rocket::{ use rocket::{
@@ -26,6 +26,7 @@ use crate::{
models::{ models::{
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
custom_roles_enabled,
}, },
}, },
error::{Error, MapResult}, error::{Error, MapResult},
@@ -256,6 +257,7 @@ struct AdminTemplateData {
logged_in: bool, logged_in: bool,
urlpath: String, urlpath: String,
sso_enabled: bool, sso_enabled: bool,
custom_roles_enabled: bool,
} }
impl AdminTemplateData { impl AdminTemplateData {
@@ -266,6 +268,7 @@ impl AdminTemplateData {
logged_in: true, logged_in: true,
urlpath: CONFIG.domain_path(), urlpath: CONFIG.domain_path(),
sso_enabled: CONFIG.sso_enabled(), sso_enabled: CONFIG.sso_enabled(),
custom_roles_enabled: custom_roles_enabled(),
} }
} }
@@ -390,6 +393,18 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string())); usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
// The clients are shown a Manager as a Custom member, but the admin panel tells the two roles apart
let roles: HashMap<String, i32> = Membership::find_confirmed_by_user(&u.uuid, &conn)
.await
.into_iter()
.map(|member| (member.org_uuid.to_string(), member.atype))
.collect();
for org in usr["organizations"].as_array_mut().into_iter().flatten() {
if let Some(atype) = org["id"].as_str().and_then(|org_id| roles.get(org_id)) {
org["type"] = json!(atype);
}
}
users_json.push(usr); users_json.push(usr);
} }
@@ -565,10 +580,14 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
err!("The specified user isn't member of the organization") err!("The specified user isn't member of the organization")
}; };
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) { let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
new_type as i32 // Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles
} else { // are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one.
err!("Invalid type") Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => {
MembershipType::Manager
}
Some(new_type) => new_type,
None => err!("Invalid type"),
}; };
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner { if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
@@ -578,7 +597,14 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
} }
} }
member_to_edit.atype = new_type; // A Manager only becomes a Custom member if that changes nothing they may do
if new_type == MembershipType::Custom
&& let Some(loss) = member_to_edit.custom_role_conversion_loss(&conn).await
{
err!(loss)
}
member_to_edit.set_type(new_type);
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type // This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
+410 -66
View File
@@ -22,8 +22,8 @@ use crate::{
models::{ models::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership,
MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, SignatureAlgorithm,
UserKdfType, User, UserId, UserKdfType, UserSignatureKeyPair,
}, },
}, },
mail, mail,
@@ -42,6 +42,7 @@ pub fn routes() -> Vec<rocket::Route> {
post_profile, post_profile,
put_avatar, put_avatar,
get_public_keys, get_public_keys,
get_account_public_keys,
get_keys, get_keys,
post_keys, post_keys,
post_password, post_password,
@@ -113,6 +114,9 @@ pub struct RegisterData {
#[serde(alias = "userAsymmetricKeys")] #[serde(alias = "userAsymmetricKeys")]
keys: Option<KeysData>, keys: Option<KeysData>,
// Supersedes `keys`, and the only way a v2 account can be registered.
account_keys: Option<AccountKeysData>,
master_password_hint: Option<String>, master_password_hint: Option<String>,
organization_user_id: Option<MembershipId>, organization_user_id: Option<MembershipId>,
@@ -124,36 +128,6 @@ pub struct RegisterData {
org_invite_token: Option<String>, org_invite_token: Option<String>,
} }
impl RegisterData {
fn hash(&self) -> String {
self.compat.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned()
}
fn kdf(&self) -> &KDFData {
self.compat.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf)
}
fn key(&self) -> String {
self.compat.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned()
}
// When comparing with salt, email need to be normalized:
// - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171
fn unprocessable(&self) -> bool {
let mut unprocessable = false;
*self.compat.fold(
|_| &false,
|rdcu| {
let email = self.email.trim().to_lowercase();
unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf
|| rdcu.master_password_authentication.salt != email
|| rdcu.master_password_unlock.salt != email;
&unprocessable
},
)
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct RegisterDataOld { struct RegisterDataOld {
#[serde(flatten)] #[serde(flatten)]
@@ -193,6 +167,42 @@ impl RegisterDataCompat {
RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu), RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu),
} }
} }
fn hash(&self) -> String {
self.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned()
}
fn kdf(&self) -> &KDFData {
self.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf)
}
fn key(&self) -> String {
self.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned()
}
/// The id of the user key, which only the current format carries.
fn key_id(&self) -> Option<KeyId> {
match self {
RegisterDataCompat::RegisterDataOld(_) => None,
RegisterDataCompat::RegisterDataCur(rdcu) => rdcu.master_password_unlock.contained_key_id.clone(),
}
}
// When comparing with salt, email need to be normalized:
// - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171
fn unprocessable(&self, email: &str) -> bool {
let mut unprocessable = false;
*self.fold(
|_| &false,
|rdcu| {
let email = email.trim().to_lowercase();
unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf
|| rdcu.master_password_authentication.salt != email
|| rdcu.master_password_unlock.salt != email;
&unprocessable
},
)
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -202,6 +212,198 @@ struct KeysData {
public_key: String, public_key: String,
} }
/// Upstream's implicit `[Required]` on a non-nullable string: present and not blank.
fn required(value: Option<String>, field: &str) -> ApiResult<String> {
match value {
Some(value) if !value.trim().is_empty() => Ok(value),
_ => err!(format!("The {field} field is required.")),
}
}
/// The `accountKeys` payload: a v1 key pair, or v2 with a signature key pair and security state.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs#L6-L50>
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AccountKeysData {
// Required like upstream, but only used without `public_key_encryption_key_pair`
user_key_encrypted_account_private_key: Option<String>,
account_public_key: Option<String>,
public_key_encryption_key_pair: Option<PublicKeyEncryptionKeyPairData>,
signature_key_pair: Option<SignatureKeyPairData>,
security_state: Option<SecurityStateData>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PublicKeyEncryptionKeyPairData {
wrapped_private_key: String,
public_key: String,
signed_public_key: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SignatureKeyPairData {
signature_algorithm: String,
wrapped_signing_key: String,
verifying_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SecurityStateData {
security_state: String,
security_version: i32,
}
struct ValidatedAccountKeys {
private_key: String,
public_key: String,
v2: Option<ValidatedV2AccountKeys>,
}
struct ValidatedV2AccountKeys {
signed_public_key: String,
signing_key: String,
verifying_key: String,
signature_algorithm: SignatureAlgorithm,
security_state: String,
security_version: i32,
}
impl AccountKeysData {
/// Upstream's model checks, plus the v2 fields all or none: no client can unlock a partial v2 state.
fn validate(self) -> ApiResult<ValidatedAccountKeys> {
let top_private_key =
required(self.user_key_encrypted_account_private_key, "UserKeyEncryptedAccountPrivateKey")?;
let top_public_key = required(self.account_public_key, "AccountPublicKey")?;
let (private_key, public_key, signed_public_key) = match self.public_key_encryption_key_pair {
Some(key_pair) => (
required(Some(key_pair.wrapped_private_key), "WrappedPrivateKey")?,
required(Some(key_pair.public_key), "PublicKey")?,
key_pair.signed_public_key,
),
// Older clients only send the top-level fields, which are always v1
None => (top_private_key, top_public_key, None),
};
if let Some(signature_key_pair) = &self.signature_key_pair {
required(Some(signature_key_pair.signature_algorithm.clone()), "SignatureAlgorithm")?;
required(Some(signature_key_pair.wrapped_signing_key.clone()), "WrappedSigningKey")?;
required(Some(signature_key_pair.verifying_key.clone()), "VerifyingKey")?;
}
if let Some(security_state) = &self.security_state {
required(Some(security_state.security_state.clone()), "SecurityState")?;
if security_state.security_state.encode_utf16().count() > 10_000 {
err!("The field SecurityState must be a string with a maximum length of 10000.")
}
}
let v2 = match (signed_public_key, self.signature_key_pair, self.security_state) {
(Some(signed_public_key), Some(signature_key_pair), Some(security_state)) => {
// Upstream fails on a null one, but takes an empty one that no client can unlock with
let signed_public_key = required(Some(signed_public_key), "SignedPublicKey")?;
let Some(signature_algorithm) = SignatureAlgorithm::parse(&signature_key_pair.signature_algorithm)
else {
err!(format!(
"Unsupported signature algorithm: {}",
signature_key_pair.signature_algorithm.escape_debug()
))
};
Some(ValidatedV2AccountKeys {
signed_public_key,
signing_key: signature_key_pair.wrapped_signing_key,
verifying_key: signature_key_pair.verifying_key,
signature_algorithm,
security_state: security_state.security_state,
security_version: security_state.security_version,
})
}
(None, None, None) => None,
_ => err!(
"Invalid account keys: the signed public key, signature key pair and security state must either all be present or all be absent"
),
};
Ok(ValidatedAccountKeys {
private_key,
public_key,
v2,
})
}
}
impl From<KeysData> for ValidatedAccountKeys {
fn from(keys: KeysData) -> Self {
Self {
private_key: keys.encrypted_private_key,
public_key: keys.public_key,
v2: None,
}
}
}
impl ValidatedAccountKeys {
/// The keys of a registration: `accountKeys` only for a v2 account, like upstream.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs#L77-L100>
fn for_registration(account_keys: Option<AccountKeysData>, keys: Option<KeysData>) -> ApiResult<Self> {
if let Some(account_keys) = account_keys {
let account_keys = account_keys.validate()?;
if account_keys.is_v2() {
return Ok(account_keys);
}
}
let Some(keys) = keys else {
err!("PublicKey and WrappedPrivateKey not found in RequestModel")
};
Ok(keys.into())
}
fn is_v2(&self) -> bool {
self.v2.is_some()
}
/// Sets the keys on the user, which then needs saving before [`Self::save_signature_key_pair`].
fn apply(&self, user: &mut User) -> EmptyResult {
user.private_key = Some(self.private_key.clone());
user.public_key = Some(self.public_key.clone());
user.signed_public_key = self.v2.as_ref().map(|v2| v2.signed_public_key.clone());
user.security_state = self.v2.as_ref().map(|v2| v2.security_state.clone());
user.security_version = self.v2.as_ref().map(|v2| v2.security_version);
Ok(())
}
/// Saves the signature key pair, which needs the user to exist for its foreign key.
async fn save_signature_key_pair(&self, user_id: &UserId, conn: &DbConn) -> EmptyResult {
// Skip if the account is v1, since v1 accounts don't have a signature key pair.
let Some(v2) = &self.v2 else {
return Ok(());
};
let mut key_pair = match UserSignatureKeyPair::find_by_user(user_id, conn).await {
Some(mut key_pair) => {
key_pair.signature_algorithm = v2.signature_algorithm as i32;
key_pair.signing_key.clone_from(&v2.signing_key);
key_pair.verifying_key.clone_from(&v2.verifying_key);
key_pair
}
None => UserSignatureKeyPair::new(
user_id.clone(),
v2.signature_algorithm,
v2.signing_key.clone(),
v2.verifying_key.clone(),
),
};
key_pair.save(conn).await
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MasterPasswordAuthentication { pub struct MasterPasswordAuthentication {
@@ -222,17 +424,19 @@ pub struct MasterPasswordUnlock {
#[serde(alias = "masterKeyWrappedUserKey")] #[serde(alias = "masterKeyWrappedUserKey")]
key: String, key: String,
contained_key_id: Option<KeyId>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SetPasswordData { pub struct SetPasswordData {
#[serde(flatten)] #[serde(flatten)]
kdf: KDFData, compat: RegisterDataCompat,
key: String,
keys: Option<KeysData>, keys: Option<KeysData>,
master_password_hash: String, // Supersedes `keys`, and the only way a v2 account can be initialized here.
account_keys: Option<AccountKeysData>,
master_password_hint: Option<String>, master_password_hint: Option<String>,
org_identifier: Option<String>, org_identifier: Option<String>,
} }
@@ -274,7 +478,7 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
let mut name = None; let mut name = None;
let mut pending_emergency_access = None; let mut pending_emergency_access = None;
if data.unprocessable() { if data.compat.unprocessable(&data.email) {
err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code); err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code);
} }
@@ -343,10 +547,11 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
err!("The field Name must be a string with a maximum length of 50."); err!("The field Name must be a string with a maximum length of 50.");
} }
// Check against the password hint setting here so if it fails, the user // Check against the password hint setting and the keys here so if they fail,
// can retry without losing their invitation below. // the user can retry without losing their invitation below.
let password_hint = clean_password_hint(data.master_password_hint.as_ref()); let password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(password_hint.as_ref())?; enforce_password_hint_setting(password_hint.as_ref())?;
let account_keys = ValidatedAccountKeys::for_registration(data.account_keys, data.keys)?;
let mut user = match User::find_by_mail(&email, &conn).await { let mut user = match User::find_by_mail(&email, &conn).await {
Some(user) => { Some(user) => {
@@ -393,9 +598,9 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
// Make sure we don't leave a lingering invitation. // Make sure we don't leave a lingering invitation.
Invitation::take(&email, &conn).await; Invitation::take(&email, &conn).await;
set_kdf_data(&mut user, data.kdf())?; set_kdf_data(&mut user, data.compat.kdf())?;
user.set_password(&data.hash(), Some(data.key()), true, None, &conn).await?; user.set_password(&data.compat.hash(), Some(data.compat.key()), true, None, &conn).await?;
user.password_hint = password_hint; user.password_hint = password_hint;
// Add extra fields if present // Add extra fields if present
@@ -403,9 +608,10 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
user.name = name; user.name = name;
} }
if let Some(keys) = data.keys { account_keys.apply(&mut user)?;
user.private_key = Some(keys.encrypted_private_key); // Like upstream, only a v2 registration records the key id; v1 accounts report it through `user-key-id`
user.public_key = Some(keys.public_key); if account_keys.is_v2() {
user.key_id = data.compat.key_id();
} }
if email_verified { if email_verified {
@@ -428,6 +634,7 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
} }
user.save(&conn).await?; user.save(&conn).await?;
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
// accept any open emergency access invitations // accept any open emergency access invitations
if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() { if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() {
@@ -441,25 +648,81 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
}))) })))
} }
/// Upstream's shape checks of the set-password body, on the raw JSON that the untagged compat would hide.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs#L75-L106>
fn validate_set_password_shape(body: &Value) -> EmptyResult {
let has = |name: &str| {
body.as_object().is_some_and(|o| o.iter().any(|(k, v)| k.eq_ignore_ascii_case(name) && !v.is_null()))
};
if has("accountKeys") && has("keys") {
err!("Cannot specify both AccountKeys and Keys. Provide exactly one keypair.")
}
let (authentication, unlock) = (has("masterPasswordAuthentication"), has("masterPasswordUnlock"));
if authentication != unlock {
err!(
"Must provide both MasterPasswordAuthentication and MasterPasswordUnlock together. Cannot provide one without the other."
)
}
if authentication && (has("masterPasswordHash") || has("key") || has("kdf")) {
err!(
"Cannot mix modern (MasterPasswordAuthentication/MasterPasswordUnlock) and legacy (MasterPasswordHash/Key/Kdf) fields. Provide one shape or the other."
)
}
Ok(())
}
#[post("/accounts/set-password", data = "<data>")] #[post("/accounts/set-password", data = "<data>")]
async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn: DbConn) -> JsonResult { async fn post_set_password(data: Json<Value>, headers: Headers, conn: DbConn) -> JsonResult {
let data: SetPasswordData = data.into_inner(); let data = data.into_inner();
validate_set_password_shape(&data)?;
let Ok(data) = serde_json::from_value::<SetPasswordData>(data) else {
err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code)
};
if data.master_password_hint.as_ref().is_some_and(|h| h.encode_utf16().count() > 50) {
err!("The field MasterPasswordHint must be a string with a maximum length of 50.")
}
let mut user = headers.user; let mut user = headers.user;
if user.private_key.is_some() || !user.password_hash.is_empty() { if user.private_key.is_some() || !user.password_hash.is_empty() {
err!("Account already initialized, cannot set password") err!("Account already initialized, cannot set password")
} }
if data.compat.unprocessable(&user.email) {
err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code);
}
// Check against the password hint setting here so if it fails, // Check against the password hint setting here so if it fails,
// the user can retry without losing their invitation below. // the user can retry without losing their invitation below.
let password_hint = clean_password_hint(data.master_password_hint.as_ref()); let password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(password_hint.as_ref())?; enforce_password_hint_setting(password_hint.as_ref())?;
set_kdf_data(&mut user, &data.kdf)?; // Like upstream, `accountKeys` only through the v2 JIT flow
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Auth/Controllers/AccountsController.cs#L314-L359>
let account_keys = match data.account_keys {
Some(account_keys) => {
if !matches!(data.compat, RegisterDataCompat::RegisterDataCur(_))
|| !crate::util::is_client_feature_flag_enabled(
"enable-account-encryption-v2-jit-password-registration",
)
{
err!("Request includes V2 AccountKeys but V2 encryption is not enabled.")
}
let account_keys = account_keys.validate()?;
if !account_keys.is_v2() {
err!("AccountKeys are only supported for V2 encryption.")
}
Some(account_keys)
}
None => data.keys.map(ValidatedAccountKeys::from),
};
set_kdf_data(&mut user, data.compat.kdf())?;
user.set_password( user.set_password(
&data.master_password_hash, &data.compat.hash(),
Some(data.key), Some(data.compat.key()),
false, false,
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
&conn, &conn,
@@ -467,9 +730,12 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
.await?; .await?;
user.password_hint = password_hint; user.password_hint = password_hint;
if let Some(keys) = data.keys { if let Some(ref account_keys) = account_keys {
user.private_key = Some(keys.encrypted_private_key); account_keys.apply(&mut user)?;
user.public_key = Some(keys.public_key); // As in `register`, only a v2 account records the user key id here
if account_keys.is_v2() {
user.key_id = data.compat.key_id();
}
} }
if let Some(identifier) = data.org_identifier if let Some(identifier) = data.org_identifier
@@ -497,6 +763,10 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
user.save(&conn).await?; user.save(&conn).await?;
if let Some(account_keys) = account_keys {
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
}
Ok(Json(json!({ Ok(Json(json!({
"object": "set-password", "object": "set-password",
}))) })))
@@ -577,8 +847,19 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js
}))) })))
} }
#[get("/users/<user_id>/keys")]
async fn get_account_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> JsonResult {
let user = match User::find_by_uuid(&user_id, &conn).await {
Some(user) if user.public_key.is_some() => user,
Some(_) => err_code!("User has no public_key", Status::NotFound.code),
None => err_code!("User doesn't exist", Status::NotFound.code),
};
Ok(Json(user.public_keys_json(&conn).await))
}
#[get("/accounts/keys")] #[get("/accounts/keys")]
fn get_keys(headers: Headers) -> JsonResult { async fn get_keys(headers: Headers, conn: DbConn) -> JsonResult {
let user = headers.user; let user = headers.user;
// The SDK reads a 404 as the user having no key pair yet // The SDK reads a 404 as the user having no key pair yet
@@ -590,29 +871,66 @@ fn get_keys(headers: Headers) -> JsonResult {
"key": (!user.akey.is_empty()).then_some(&user.akey), "key": (!user.akey.is_empty()).then_some(&user.akey),
"publicKey": user.public_key, "publicKey": user.public_key,
"privateKey": user.private_key, "privateKey": user.private_key,
"accountKeys": user.account_keys_json(), "accountKeys": user.account_keys_json(&conn).await,
"object": "keys", "object": "keys",
}))) })))
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PostKeysData {
// Required like upstream, even next to `accountKeys`
public_key: Option<String>,
encrypted_private_key: Option<String>,
account_keys: Option<AccountKeysData>,
// The id of the user key these account keys belong to, only honored for v2 `accountKeys`
user_key_id: Option<KeyId>,
}
#[post("/accounts/keys", data = "<data>")] #[post("/accounts/keys", data = "<data>")]
async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> JsonResult { async fn post_keys(data: Json<PostKeysData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: KeysData = data.into_inner(); let data: PostKeysData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
// Only for an account without keys, replacing them is what a key rotation does
if user.private_key.is_some() || user.public_key.is_some() { if user.private_key.is_some() || user.public_key.is_some() {
err!("User has existing keypair") err!("User has existing keypair")
} }
user.private_key = Some(data.encrypted_private_key); // Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs#L11-L19>
user.public_key = Some(data.public_key); let public_key = required(data.public_key, "PublicKey")?;
let encrypted_private_key = required(data.encrypted_private_key, "EncryptedPrivateKey")?;
// `accountKeys` supersedes the flat keys when both are sent
let account_keys = match data.account_keys {
Some(account_keys) => {
let account_keys = account_keys.validate()?;
if !account_keys.is_v2() {
err!("AccountKeys are only supported for V2 encryption.")
}
// A client that predates key ids sends none, and reports it later through `user-key-id`
if data.user_key_id.is_some() {
user.key_id = data.user_key_id;
}
account_keys
}
None => KeysData {
encrypted_private_key,
public_key,
}
.into(),
};
account_keys.apply(&mut user)?;
user.save(&conn).await?; user.save(&conn).await?;
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
Ok(Json(json!({ Ok(Json(json!({
"key": (!user.akey.is_empty()).then_some(&user.akey),
"privateKey": user.private_key, "privateKey": user.private_key,
"publicKey": user.public_key, "publicKey": user.public_key,
"accountKeys": user.account_keys_json(&conn).await,
"object":"keys" "object":"keys"
}))) })))
} }
@@ -651,6 +969,8 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
err!("Invalid master password salt") err!("Invalid master password salt")
} }
validate_key_id_unchanged(&user, &unlock_data)?;
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key) (authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) { } else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
(new_master_password_hash, new_key) (new_master_password_hash, new_key)
@@ -753,6 +1073,19 @@ pub(super) struct UnlockData {
salt: String, salt: String,
kdf: KDFData, kdf: KDFData,
pub(super) master_key_wrapped_user_key: String, pub(super) master_key_wrapped_user_key: String,
contained_key_id: Option<KeyId>,
}
/// A password or KDF change keeps the user key, so its key id must be the current one, when both are known.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs#L27-L47>
fn validate_key_id_unchanged(user: &User, unlock_data: &UnlockData) -> EmptyResult {
if let (Some(current), Some(contained)) = (&user.key_id, &unlock_data.contained_key_id)
&& current != contained
{
err!("Invalid user key sent in master-password unlock data.")
}
Ok(())
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -773,6 +1106,8 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
data.authentication_data.check(&headers.user, &data.unlock_data)?; data.authentication_data.check(&headers.user, &data.unlock_data)?;
validate_key_id_unchanged(&headers.user, &data.unlock_data)?;
let mut user = headers.user; let mut user = headers.user;
set_kdf_data(&mut user, &data.unlock_data.kdf)?; set_kdf_data(&mut user, &data.unlock_data.kdf)?;
@@ -948,6 +1283,14 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
err!("Invalid password") err!("Invalid password")
} }
// Rotating v2 keys, or upgrading to them, would leave an account that can't be unlocked here
if headers.user.is_v2() {
err!("Key rotation is not supported for v2 accounts")
}
if !data.account_keys.user_key_encrypted_account_private_key.starts_with("2.") {
err!("The provided account private key was not wrapped with AES-256-CBC-HMAC")
}
// Validate the import before continuing // Validate the import before continuing
// Bitwarden does not process the import if there is one item invalid. // Bitwarden does not process the import if there is one item invalid.
// Since we check for the size of the encrypted note length, we need to do that here to pre-validate it. // Since we check for the size of the encrypted note length, we need to do that here to pre-validate it.
@@ -1073,8 +1416,9 @@ struct KeyIdData {
#[post("/accounts/key-management/user-key-id", data = "<data>")] #[post("/accounts/key-management/user-key-id", data = "<data>")]
async fn post_user_key(data: Json<KeyIdData>, headers: Headers, conn: DbConn) -> EmptyResult { async fn post_user_key(data: Json<KeyIdData>, headers: Headers, conn: DbConn) -> EmptyResult {
let mut user = headers.user; let mut user = headers.user;
// Only a backfill for accounts that have none. Afterwards the id changes with the key, in a rotation.
if user.key_id.is_some() { if user.key_id.is_some() {
err_code!("Unexpected data", Status::UnprocessableEntity.code); err!("User key id is already set.")
} }
user.key_id = Some(data.into_inner().user_key_id); user.key_id = Some(data.into_inner().user_key_id);
@@ -1958,10 +2302,10 @@ mod tests {
) )
.unwrap(); .unwrap();
assert!(!data.unprocessable()); assert!(!data.compat.unprocessable(&data.email));
assert_eq!(data.hash(), "hash"); assert_eq!(data.compat.hash(), "hash");
assert_eq!(data.key(), "key"); assert_eq!(data.compat.key(), "key");
assert_eq!(data.kdf().kdf_iterations, 600_000); assert_eq!(data.compat.kdf().kdf_iterations, 600_000);
assert!(data.keys.is_some()); assert!(data.keys.is_some());
assert_eq!(data.email_verification_token.as_deref(), Some("token")); assert_eq!(data.email_verification_token.as_deref(), Some("token"));
} }
@@ -1983,10 +2327,10 @@ mod tests {
) )
.unwrap(); .unwrap();
assert!(!data.unprocessable()); assert!(!data.compat.unprocessable(&data.email));
assert_eq!(data.hash(), "hash"); assert_eq!(data.compat.hash(), "hash");
assert_eq!(data.key(), "key"); assert_eq!(data.compat.key(), "key");
assert_eq!(data.kdf().kdf_iterations, 600_000); assert_eq!(data.compat.kdf().kdf_iterations, 600_000);
assert!(data.keys.is_some()); assert!(data.keys.is_some());
} }
} }
+224 -78
View File
@@ -24,6 +24,7 @@ use crate::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
is_data_blob_encrypted,
}, },
}, },
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@@ -189,12 +190,19 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"masterKeyEncryptedUserKey": headers.user.akey, "masterKeyEncryptedUserKey": headers.user.akey,
"masterKeyWrappedUserKey": headers.user.akey, "masterKeyWrappedUserKey": headers.user.akey,
"salt": headers.user.email "salt": headers.user.email,
"containedKeyId": headers.user.key_id,
}) })
} else { } else {
Value::Null Value::Null
}; };
// Upstream omits these when unset rather than sending null
let mut user_decryption = json!({ "masterPasswordUnlock": master_password_unlock });
if let Some(key_id) = &headers.user.key_id {
user_decryption["userKeyId"] = json!(key_id);
}
Ok(Json(json!({ Ok(Json(json!({
"profile": user_json, "profile": user_json,
"folders": folders_json, "folders": folders_json,
@@ -204,10 +212,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"ciphers": ciphers_json, "ciphers": ciphers_json,
"domains": domains_json, "domains": domains_json,
"sends": sends_json, "sends": sends_json,
"userDecryption": { "userDecryption": user_decryption,
"masterPasswordUnlock": master_password_unlock,
"userKeyId": headers.user.key_id,
},
"object": "sync" "object": "sync"
}))) })))
} }
@@ -269,7 +274,8 @@ pub struct CipherData {
key: Option<String>, key: Option<String>,
pub encrypted_for: UserId, // Added in web-v2025.6.0 // Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent
pub encrypted_for: Option<UserId>,
// Added in web-v2025.8.1, Optional for compat // Added in web-v2025.8.1, Optional for compat
pub encrypted_by_key_id: Option<KeyId>, pub encrypted_by_key_id: Option<KeyId>,
@@ -284,7 +290,8 @@ pub struct CipherData {
Passport = 8 Passport = 8
*/ */
pub r#type: i32, pub r#type: i32,
pub name: String, // Absent on a blob-encrypted cipher, whose name is sealed inside `data`
pub name: Option<String>,
pub notes: Option<String>, pub notes: Option<String>,
fields: Option<Value>, fields: Option<Value>,
@@ -298,6 +305,9 @@ pub struct CipherData {
drivers_license: Option<Value>, drivers_license: Option<Value>,
passport: Option<Value>, passport: Option<Value>,
// The sealed blob of a v2 account's cipher, which replaces all of the fields above
data: Option<String>,
favorite: Option<bool>, favorite: Option<bool>,
reprompt: Option<i32>, reprompt: Option<i32>,
@@ -319,6 +329,98 @@ pub struct CipherData {
archived_date: Option<String>, archived_date: Option<String>,
} }
/// A field of a [`CipherData`] that fails upstream's model validation.
#[derive(Debug)]
pub struct CipherValidationError {
pub field: &'static str,
pub message: String,
}
impl From<CipherValidationError> for crate::Error {
fn from(e: CipherValidationError) -> Self {
Self::new_msg(e.message)
}
}
/// The cipher must have been encrypted for the acting user. Only checked when the client sends the field.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1857-L1870>
fn validate_encrypted_for_user(data: &CipherData, user_id: &UserId) -> EmptyResult {
if data.encrypted_for.as_ref().is_some_and(|encrypted_for| encrypted_for != user_id) {
err!("Cipher was not encrypted for the current user. Please try again.")
}
Ok(())
}
/// [`validate_encrypted_for_user`], plus the key id of a user-owned cipher, when both ids are known.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1886-L1911>
fn validate_encrypted_by_user(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult {
validate_encrypted_for_user(data, &user.uuid)?;
if !is_org_cipher
&& let (Some(cipher_key_id), Some(user_key_id)) = (&data.encrypted_by_key_id, &user.key_id)
&& cipher_key_id != user_key_id
{
err!("Cipher was not encrypted with the current user key. Please try again.")
}
Ok(())
}
/// Upstream's `[StringLength(500000)]` on `CipherRequestModel.Data`
const MAX_CIPHER_DATA_LENGTH: usize = 500_000;
impl CipherData {
/// Whether `data` is a blob rather than the per-type fields. Parses `data`, so keep the result.
pub fn is_blob(&self) -> bool {
self.data.as_deref().is_some_and(is_data_blob_encrypted)
}
/// Saving's last checks: a known type, and its data unless it's a blob.
pub fn validate_type_data(&self, is_blob: bool) -> EmptyResult {
let type_data = match self.r#type {
1 => &self.login,
2 => &self.secure_note,
3 => &self.card,
4 => &self.identity,
5 => &self.ssh_key,
6 => &self.bank_account,
7 => &self.drivers_license,
8 => &self.passport,
_ => err!("Invalid type"),
};
if !is_blob && type_data.is_none() {
err!("Data missing")
}
Ok(())
}
/// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L76-L77>
/// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L91-L99>
pub fn validate_content(&self, is_blob: bool) -> Result<(), CipherValidationError> {
if let Some(data) = &self.data
&& data.len() > MAX_CIPHER_DATA_LENGTH
{
return Err(CipherValidationError {
field: "Data",
message: format!("The field Data must be a string with a maximum length of {MAX_CIPHER_DATA_LENGTH}."),
});
}
// A blob carries the name inside it, so only the other formats need one
if !is_blob && self.name.as_deref().is_none_or(|n| n.trim().is_empty()) {
return Err(CipherValidationError {
field: "Name",
message: String::from("The Name field is required."),
});
}
Ok(())
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PartialCipherData { pub struct PartialCipherData {
@@ -352,16 +454,14 @@ async fn post_ciphers_create(
) -> JsonResult { ) -> JsonResult {
let mut data: ShareCipherData = data.into_inner(); let mut data: ShareCipherData = data.into_inner();
if data.cipher.encrypted_for != headers.user.uuid { validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?;
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
// This check is usually only needed in update_cipher_from_data(), but we // This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to // need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below. // cipher.save() below.
enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?; enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?;
let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone()); let mut cipher = Cipher::new(data.cipher.r#type);
cipher.user_uuid = Some(headers.user.uuid.clone()); cipher.user_uuid = Some(headers.user.uuid.clone());
cipher.save(&conn).await?; cipher.save(&conn).await?;
@@ -385,16 +485,7 @@ async fn post_ciphers_create(
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let mut data: CipherData = data.into_inner(); let mut data: CipherData = data.into_inner();
if data.encrypted_for != headers.user.uuid { validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?;
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
if let Some(cipher_key_id) = &data.encrypted_by_key_id
&& let Some(user_key_id) = &headers.user.key_id
&& cipher_key_id != user_key_id
{
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
}
// The web/browser clients set this field to null as expected, but the // The web/browser clients set this field to null as expected, but the
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`, // mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
@@ -402,7 +493,7 @@ async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt
// needed when creating a new cipher, so just ignore it unconditionally. // needed when creating a new cipher, so just ignore it unconditionally.
data.last_known_revision_date = None; data.last_known_revision_date = None;
let mut cipher = Cipher::new(data.r#type, data.name.clone()); let mut cipher = Cipher::new(data.r#type);
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?; update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
@@ -426,6 +517,44 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: &
Ok(()) Ok(())
} }
/// The checks of saving a cipher, for callers that run them before writing anything else
async fn validate_cipher_update(
cipher: &Cipher,
data: &CipherData,
headers: &Headers,
conn: &DbConn,
ut: UpdateType,
) -> EmptyResult {
// Check that the client isn't updating an existing cipher with stale data.
// And only perform this check when not importing ciphers, else the date/time check will fail.
if ut != UpdateType::None
&& let Some(dt) = &data.last_known_revision_date
{
match NaiveDateTime::parse_from_str(dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
Ok(_) => (),
}
}
if let Some(note) = &data.notes {
let max_note_size = CONFIG._max_note_size();
if note.len() > max_note_size {
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
}
}
if let Some(folder_id) = &data.folder_id
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
{
err!("Invalid folder", "Folder does not exist or belongs to another user");
}
Ok(())
}
pub async fn update_cipher_from_data( pub async fn update_cipher_from_data(
cipher: &mut Cipher, cipher: &mut Cipher,
data: CipherData, data: CipherData,
@@ -451,32 +580,15 @@ pub async fn update_cipher_from_data(
enforce_personal_ownership_policy(Some(&data), headers, conn).await?; enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
// Check that the client isn't updating an existing cipher with stale data. let is_blob = data.is_blob();
// And only perform this check when not importing ciphers, else the date/time check will fail. data.validate_content(is_blob)?;
if ut != UpdateType::None data.validate_type_data(is_blob)?;
&& let Some(dt) = data.last_known_revision_date validate_cipher_update(cipher, &data, headers, conn, ut).await?;
{
match NaiveDateTime::parse_from_str(&dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
Ok(_) => (),
}
}
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id { if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
err!("Organization mismatch. Please resync the client before updating the cipher") err!("Organization mismatch. Please resync the client before updating the cipher")
} }
if let Some(note) = &data.notes {
let max_note_size = CONFIG._max_note_size();
if note.len() > max_note_size {
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
}
}
// Check if this cipher is being transferred from a personal to an organization vault // Check if this cipher is being transferred from a personal to an organization vault
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some(); let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
@@ -507,12 +619,6 @@ pub async fn update_cipher_from_data(
cipher.user_uuid = Some(headers.user.uuid.clone()); cipher.user_uuid = Some(headers.user.uuid.clone());
} }
if let Some(ref folder_id) = data.folder_id
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
{
err!("Invalid folder", "Folder does not exist or belongs to another user");
}
// Modify attachments name and keys when rotating // Modify attachments name and keys when rotating
if let Some(attachments) = data.attachments2 { if let Some(attachments) = data.attachments2 {
for (id, attachment) in attachments { for (id, attachment) in attachments {
@@ -551,26 +657,36 @@ pub async fn update_cipher_from_data(
_ => err!("Invalid type"), _ => err!("Invalid type"),
}; };
let type_data = if let Some(mut data) = type_data_opt { if let Some(blob) = data.data.filter(|_| is_blob) {
// A blob holds everything, the name included; the column can't be null, so it's left empty
// TODO: Make `ciphers.name` nullable and store `None` here instead.
cipher.name = String::new();
cipher.notes = None;
cipher.fields = None;
cipher.password_history = None;
cipher.data = blob;
} else {
let Some(mut type_data) = type_data_opt else {
err!("Data missing")
};
// Remove the 'Response' key from the base object. // Remove the 'Response' key from the base object.
if let Some(data_obj) = data.as_object_mut() { if let Some(data_obj) = type_data.as_object_mut() {
data_obj.remove("response"); data_obj.remove("response");
} }
// Remove the 'Response' key from every Uri. // Remove the 'Response' key from every Uri.
if data["uris"].is_array() { if type_data["uris"].is_array() {
data["uris"] = clean_cipher_data(data["uris"].clone()); type_data["uris"] = clean_cipher_data(type_data["uris"].clone());
} }
data
} else {
err!("Data missing")
};
cipher.key = data.key; // `validate_content` made sure there is a name
cipher.name = data.name; cipher.name = data.name.unwrap_or_default();
cipher.notes = data.notes; cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string()); cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.data = type_data.to_string();
cipher.password_history = data.password_history.map(|f| f.to_string()); cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.data = type_data.to_string();
}
cipher.key = data.key;
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32); cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
cipher.save(conn).await?; cipher.save(conn).await?;
@@ -670,7 +786,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
// Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58 // Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58
cipher_data.organization_id = None; cipher_data.organization_id = None;
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
} }
@@ -743,6 +859,9 @@ async fn put_cipher(
err!("Cipher is not write accessible") err!("Cipher is not write accessible")
} }
// Against the cipher we hold rather than the organization the client claims, like upstream
validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?;
// Moving a cipher into an organization is only possible through the share endpoints // Moving a cipher into an organization is only possible through the share endpoints
if cipher.organization_uuid != data.organization_id { if cipher.organization_uuid != data.organization_id {
err!("Organization mismatch. Please resync the client before updating the cipher") err!("Organization mismatch. Please resync the client before updating the cipher")
@@ -1019,13 +1138,20 @@ async fn put_cipher_share(
) -> JsonResult { ) -> JsonResult {
let data: ShareCipherData = data.into_inner(); let data: ShareCipherData = data.into_inner();
// Upstream's `PutShare` checks, before anything is written.
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L899-L912>
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L498-L519>
if data.cipher.organization_id.is_none() { if data.cipher.organization_id.is_none() {
err!("Cipher OrganizationId is required.") err!("Cipher OrganizationId is required.")
} }
if data.collection_ids.is_empty() { if data.collection_ids.is_empty() {
err!("You must select at least one collection.") err!("You must select at least one collection.")
} }
if !Cipher::find_by_uuid(&cipher_id, &conn).await.is_some_and(|c| c.user_uuid.as_ref() == Some(&headers.user.uuid))
{
err_code!("Cipher doesn't exist", Status::NotFound.code)
}
validate_encrypted_for_user(&data.cipher, &headers.user.uuid)?;
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
} }
@@ -1085,21 +1211,35 @@ async fn put_cipher_share_selected(
) -> JsonResult { ) -> JsonResult {
let data: ShareSelectedCipherData = data.into_inner(); let data: ShareSelectedCipherData = data.into_inner();
// Upstream's `PutShareMany` checks, before anything is written.
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1395-L1421>
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L560-L604>
if data.ciphers.is_empty() { if data.ciphers.is_empty() {
err!("You must select at least one cipher.") err!("You must select at least one cipher.")
} }
if data.ciphers.iter().any(|c| c.id.is_none() || c.organization_id.is_none()) {
err!("All Ciphers must have an Id and OrganizationId.")
}
if data.ciphers.iter().map(|c| &c.organization_id).collect::<HashSet<_>>().len() != 1 {
err!("All ciphers must be for the same organization.")
}
if data.collection_ids.is_empty() { if data.collection_ids.is_empty() {
err!("You must select at least one collection.") err!("You must select at least one collection.")
} }
for cipher in &data.ciphers { for cipher in &data.ciphers {
if cipher.id.is_none() { let is_blob = cipher.is_blob();
err!("Request missing ids field") cipher.validate_content(is_blob)?;
cipher.validate_type_data(is_blob)?;
} }
if cipher.organization_id.is_none() { for cipher in &data.ciphers {
err!("Cipher OrganizationId is required.") validate_encrypted_for_user(cipher, &headers.user.uuid)?;
} }
let owned_ciphers = Cipher::find_owned_by_user(&headers.user.uuid, &conn).await;
for cipher_data in &data.ciphers {
let Some(cipher) = owned_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) else {
err!("Trying to share ciphers that you do not own.")
};
validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?;
} }
let mut responses = Vec::with_capacity(data.ciphers.len()); let mut responses = Vec::with_capacity(data.ciphers.len());
@@ -1161,6 +1301,22 @@ async fn share_cipher_by_uuid(
err!("Organization mismatch. Please resync the client before updating the cipher") err!("Organization mismatch. Please resync the client before updating the cipher")
} }
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
let ut = if let Some(ut) = override_ut {
ut
} else if data.cipher.last_known_revision_date.is_some() {
UpdateType::SyncCipherUpdate
} else {
UpdateType::SyncCipherCreate
};
// For the same reason, the other checks of saving
let is_blob = data.cipher.is_blob();
data.cipher.validate_content(is_blob)?;
data.cipher.validate_type_data(is_blob)?;
validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?;
let mut shared_to_collections = vec![]; let mut shared_to_collections = vec![];
if let Some(organization_id) = &data.cipher.organization_id { if let Some(organization_id) = &data.cipher.organization_id {
@@ -1179,16 +1335,6 @@ async fn share_cipher_by_uuid(
} }
} }
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
let ut = if let Some(ut) = override_ut {
ut
} else if data.cipher.last_known_revision_date.is_some() {
UpdateType::SyncCipherUpdate
} else {
UpdateType::SyncCipherCreate
};
update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
+81 -24
View File
@@ -8,17 +8,17 @@ use crate::{
CONFIG, CONFIG,
api::admin::FAKE_ADMIN_UUID, api::admin::FAKE_ADMIN_UUID,
api::{ api::{
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
}, },
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
db::{ db::{
DbConn, DbConn,
models::{ models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser,
EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation,
MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
OrganizationId, TwoFactor, TwoFactorType, User, UserId, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled,
}, },
}, },
mail, mail,
@@ -1008,14 +1008,12 @@ async fn send_invite(
data.validate(&org_id, &conn).await?; data.validate(&org_id, &conn).await?;
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
// The from_str() will convert the custom role type into a manager role type // Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
let raw_type = &data.r#type.into_string(); let raw_type = &data.r#type.into_string();
// Membership::from_str will convert custom (4) to manager (3) let Some(requested_type) = MembershipType::from_str(raw_type) else {
let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) {
new_type as i32
} else {
err!("Invalid type") err!("Invalid type")
}; };
let new_type = MembershipType::requested(requested_type, None, custom_roles_enabled());
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
err!("Only Owners can invite Managers, Admins or Owners") err!("Only Owners can invite Managers, Admins or Owners")
@@ -1025,11 +1023,19 @@ async fn send_invite(
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
// If the box is not checked, the user will still be a manager, but not with the access_all permission // If the box is not checked, the user will still be a manager, but not with the access_all permission
let access_all = new_type >= MembershipType::Admin let access_all = new_type >= MembershipType::Admin
|| (raw_type.eq("4") || (new_type == MembershipType::Manager
&& raw_type.eq("4")
&& data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true))); && data.permissions.get("createNewCollections") == Some(&json!(true)));
// Checked before anything is created, so a rejected permission leaves nothing behind
let custom_permissions = if new_type == MembershipType::Custom {
Some(CustomPermission::requested(&data.permissions)?)
} else {
None
};
let mut user_created: bool; let mut user_created: bool;
for email in &data.emails { for email in &data.emails {
let mut member_status = MembershipStatus::Invited as i32; let mut member_status = MembershipStatus::Invited as i32;
@@ -1071,8 +1077,11 @@ async fn send_invite(
}; };
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
new_member.set_type(new_type);
new_member.access_all = access_all; new_member.access_all = access_all;
new_member.atype = new_type; if let Some(permissions) = &custom_permissions {
new_member.set_custom_permissions(permissions);
}
new_member.status = member_status; new_member.status = member_status;
new_member.save(&conn).await?; new_member.save(&conn).await?;
@@ -1508,24 +1517,32 @@ async fn edit_member(
let data: EditUserData = data.into_inner(); let data: EditUserData = data.into_inner();
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
// The from_str() will convert the custom role type into a manager role type // Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
let raw_type = &data.r#type.into_string(); let raw_type = &data.r#type.into_string();
// MembershipType::from_str will convert custom (4) to manager (3) let Some(requested_type) = MembershipType::from_str(raw_type) else {
let Some(new_type) = MembershipType::from_str(raw_type) else {
err!("Invalid type") err!("Invalid type")
}; };
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err!("The specified user isn't member of the organization")
};
let new_type = MembershipType::requested(requested_type, Some(&member_to_edit), custom_roles_enabled());
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
// If the box is not checked, the user will still be a manager, but not with the access_all permission // If the box is not checked, the user will still be a manager, but not with the access_all permission
let access_all = new_type >= MembershipType::Admin let access_all = new_type >= MembershipType::Admin
|| (raw_type.eq("4") || (new_type == MembershipType::Manager
&& raw_type.eq("4")
&& data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true))); && data.permissions.get("createNewCollections") == Some(&json!(true)));
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { // While Custom roles are disabled, a Custom member keeps their stored permissions untouched
err!("The specified user isn't member of the organization") let custom_permissions = if new_type == MembershipType::Custom && custom_roles_enabled() {
Some(CustomPermission::requested(&data.permissions)?)
} else {
None
}; };
if new_type != member_to_edit.atype if new_type != member_to_edit.atype
@@ -1549,8 +1566,11 @@ async fn edit_member(
} }
} }
member_to_edit.set_type(new_type);
member_to_edit.access_all = access_all; member_to_edit.access_all = access_all;
member_to_edit.atype = new_type as i32; if let Some(permissions) = &custom_permissions {
member_to_edit.set_custom_permissions(permissions);
}
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// We need to perform the check after changing the type since `admin` is exempt. // We need to perform the check after changing the type since `admin` is exempt.
@@ -1846,7 +1866,7 @@ async fn post_org_import(
cipher_data.folder_id = None; cipher_data.folder_id = None;
// Replace the client-provided, unvalidated organizationId with the real target org // Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone()); cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data( update_cipher_from_data(
&mut cipher, &mut cipher,
cipher_data, cipher_data,
@@ -2849,8 +2869,26 @@ struct OrganizationUserRecoverAccountRequest {
// But the clients do not seem to use this at all // But the clients do not seem to use this at all
// Just add it here in case they will // Just add it here in case they will
#[get("/organizations/<org_id>/public-key")] #[get("/organizations/<org_id>/public-key")]
async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { async fn get_organization_public_key(
if org_id != headers.membership.org_uuid { org_id: OrganizationId,
headers: Headers,
member: Result<OrgMemberHeaders, &'static str>,
conn: DbConn,
) -> JsonResult {
// SSO users without an org get the fake one, whose key the v2 JIT password flow fetches anyway
if org_id.eq_ignore_ascii_case(FAKE_SSO_IDENTIFIER) && headers.user.private_key.is_none() {
return Ok(Json(json!({
"object": "organizationPublicKey",
"publicKey": fake_sso_org_public_key().await?,
})));
}
let member = match member {
Ok(member) => member,
// The guard already logged it
Err(e) => return Err(crate::error::Error::new_msg(e).with_code(Status::Unauthorized.code)),
};
if org_id != member.membership.org_uuid {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
@@ -2863,11 +2901,30 @@ async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberH
}))) })))
} }
/// The SDK only uses this key to wrap an account recovery key that it never sends for the fake org,
/// so it is made once and its private half dropped.
async fn fake_sso_org_public_key() -> ApiResult<String> {
static PUBLIC_KEY: std::sync::LazyLock<Option<String>> = std::sync::LazyLock::new(|| {
let der = openssl::rsa::Rsa::generate(2048).and_then(|rsa| rsa.public_key_to_der()).ok()?;
Some(data_encoding::BASE64.encode(&der))
});
let Ok(Some(public_key)) = tokio::task::spawn_blocking(|| PUBLIC_KEY.clone()).await else {
err!("Failed to generate the organization key")
};
Ok(public_key)
}
// Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients // Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
#[get("/organizations/<org_id>/keys")] #[get("/organizations/<org_id>/keys")]
async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { async fn get_organization_keys(
get_organization_public_key(org_id, headers, conn).await org_id: OrganizationId,
headers: Headers,
member: Result<OrgMemberHeaders, &'static str>,
conn: DbConn,
) -> JsonResult {
get_organization_public_key(org_id, headers, member, conn).await
} }
// Will allow to reset 2FA too // Will allow to reset 2FA too
+99 -31
View File
@@ -77,14 +77,14 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
password_login(data, &mut user_id, &conn, &client_header.ip).await password_login(data, &mut user_id, &conn, &client_header.ip, client_header.client_version.as_ref()).await
} }
"client_credentials" => { "client_credentials" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?; check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
check_is_some(data.scope.as_ref(), "scope cannot be blank")?; check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
api_key_login(data, &mut user_id, &conn, &client_header.ip).await api_key_login(data, &mut user_id, &conn, &client_header.ip, client_header.client_version.as_ref()).await
} }
"authorization_code" if CONFIG.sso_enabled() => { "authorization_code" if CONFIG.sso_enabled() => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
@@ -95,7 +95,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
sso_login(data, &mut user_id, &conn, &client_header.ip).await sso_login(data, &mut user_id, &conn, &client_header.ip, client_header.client_version.as_ref()).await
} }
"authorization_code" => err!("SSO sign-in is not available"), "authorization_code" => err!("SSO sign-in is not available"),
"send_access" => { "send_access" => {
@@ -179,7 +179,13 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR
} }
// After exchanging the code we need to check first if 2FA is needed before continuing // After exchanging the code we need to check first if 2FA is needed before continuing
async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult { async fn sso_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&semver::Version>,
) -> JsonResult {
AuthMethod::Sso.check_scope(data.scope.as_ref())?; AuthMethod::Sso.check_scope(data.scope.as_ref())?;
// Ratelimit the login // Ratelimit the login
@@ -325,6 +331,8 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
(user, device, None, None) (user, device, None, None)
} }
Some((user, _)) if !user.enabled => { Some((user, _)) if !user.enabled => {
// Set in both arms, not only after the match, so that their failed logins are logged
*user_id = Some(user.uuid.clone());
err!( err!(
"This user has been disabled", "This user has been disabled",
format!("IP: {}. Username: {}.", ip.ip, user.email), format!("IP: {}. Username: {}.", ip.ip, user.email),
@@ -334,6 +342,9 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
) )
} }
Some((mut user, sso_user)) => { Some((mut user, sso_user)) => {
*user_id = Some(user.uuid.clone());
check_client_version(&user, client_version, ip)?;
let mut device = get_device(&data, conn, &user).await?; let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?; let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
@@ -368,7 +379,13 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
} }
async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult { async fn password_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&semver::Version>,
) -> JsonResult {
// Validate scope // Validate scope
AuthMethod::Password.check_scope(data.scope.as_ref())?; AuthMethod::Password.check_scope(data.scope.as_ref())?;
@@ -438,6 +455,8 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
) )
} }
check_client_version(&user, client_version, ip)?;
// Change the KDF Iterations (only when not logging in with an auth request) // Change the KDF Iterations (only when not logging in with an auth request)
if data.auth_request.is_none() { if data.auth_request.is_none() {
kdf_upgrade(&mut user, password, conn).await?; kdf_upgrade(&mut user, password, conn).await?;
@@ -499,6 +518,47 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
} }
/// Like upstream's `ClientVersionValidator`, a V2 account logs in only from a client that sends its version and is at
/// least 2025.11.0 (upstream also refuses a missing header for V1 accounts). Called right after the credentials are
/// checked, like upstream, so that it doesn't tell anyone else which accounts are V2.
///
/// Ref: <https://github.com/bitwarden/server/blob/2028f0a5cc9d8ab8ff8475029085f29f81483598/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs#L42-L69>
fn check_client_version(user: &User, client_version: Option<&semver::Version>, ip: &ClientIp) -> EmptyResult {
let Some((error, description)) = client_version_error(user.is_v2(), client_version) else {
return Ok(());
};
Err(crate::error::Error::from((
format!("IP: {}. Username: {}. {error}", ip.ip, user.email),
json!({
"error": error,
"error_description": description,
"ErrorModel": { "Message": description, "Object": "error" },
}),
))
.with_event(crate::error::ErrorEvent {
event: EventType::UserFailedLogIn,
}))
}
/// The error id and message of [`check_client_version`], `None` when the client may log in.
fn client_version_error(is_v2: bool, client_version: Option<&semver::Version>) -> Option<(&'static str, &'static str)> {
const MIN_V2_CLIENT: semver::Version = semver::Version::new(2025, 11, 0);
if !is_v2 {
return None;
}
match client_version {
None => Some((
"version_header_missing",
"No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"",
)),
Some(version) if *version < MIN_V2_CLIENT => {
Some(("invalid_client_version", "Please update your app to continue using Bitwarden"))
}
Some(_) => None,
}
}
async fn authenticated_response( async fn authenticated_response(
user: &User, user: &User,
device: &mut Device, device: &mut Device,
@@ -552,18 +612,7 @@ async fn authenticated_response(
Value::Null Value::Null
}; };
let account_keys = if user.private_key.is_some() { let account_keys = user.account_keys_json(conn).await;
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
let mut result = json!({ let mut result = json!({
"access_token": auth_tokens.access_token(), "access_token": auth_tokens.access_token(),
@@ -598,7 +647,13 @@ async fn authenticated_response(
Ok(Json(result)) Ok(Json(result))
} }
async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult { async fn api_key_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&semver::Version>,
) -> JsonResult {
// Ratelimit the login // Ratelimit the login
crate::ratelimit::check_limit_login(&ip.ip)?; crate::ratelimit::check_limit_login(&ip.ip)?;
@@ -610,7 +665,7 @@ async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &D
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
user_api_key_login(data, user_id, conn, ip).await user_api_key_login(data, user_id, conn, ip, client_version).await
} }
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await, Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
_ => err!("Scope not supported"), _ => err!("Scope not supported"),
@@ -622,6 +677,7 @@ async fn user_api_key_login(
user_id: &mut Option<UserId>, user_id: &mut Option<UserId>,
conn: &DbConn, conn: &DbConn,
ip: &ClientIp, ip: &ClientIp,
client_version: Option<&semver::Version>,
) -> JsonResult { ) -> JsonResult {
// Get the user via the client_id // Get the user via the client_id
let client_id = data.client_id.as_ref().unwrap(); let client_id = data.client_id.as_ref().unwrap();
@@ -659,6 +715,8 @@ async fn user_api_key_login(
) )
} }
check_client_version(&user, client_version, ip)?;
let mut device = get_device(&data, conn, &user).await?; let mut device = get_device(&data, conn, &user).await?;
if CONFIG.mail_enabled() && device.is_new() { if CONFIG.mail_enabled() && device.is_new() {
@@ -709,18 +767,7 @@ async fn user_api_key_login(
Value::Null Value::Null
}; };
let account_keys = if user.private_key.is_some() { let account_keys = user.account_keys_json(conn).await;
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
// Note: No refresh_token is returned. The CLI just repeats the // Note: No refresh_token is returned. The CLI just repeats the
// client_credentials login flow when the existing token expires. // client_credentials login flow when the existing token expires.
@@ -1351,3 +1398,24 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure,
Ok(Redirect::temporary(String::from(auth_url))) Ok(Redirect::temporary(String::from(auth_url)))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_version_error() {
let version = |v| semver::Version::parse(v).unwrap();
// V1 accounts aren't checked, with or without a version
assert_eq!(client_version_error(false, None), None);
assert_eq!(client_version_error(false, Some(&version("2024.1.0"))), None);
// V2 accounts need the header and 2025.11.0 or newer
assert_eq!(client_version_error(true, None).map(|e| e.0), Some("version_header_missing"));
assert_eq!(
client_version_error(true, Some(&version("2025.10.3"))).map(|e| e.0),
Some("invalid_client_version")
);
assert_eq!(client_version_error(true, Some(&version("2025.11.0"))), None);
assert_eq!(client_version_error(true, Some(&version("2026.10.1"))), None);
}
}
+2 -1
View File
@@ -20,7 +20,7 @@ use crate::{
crypto::sha256_hex, crypto::sha256_hex,
db::{ db::{
DbConn, DbConn,
models::{AttachmentId, CipherId}, models::{AttachmentId, CipherId, custom_roles_enabled},
}, },
error::Error, error::Error,
util::{Cached, EtagCached}, util::{Cached, EtagCached},
@@ -90,6 +90,7 @@ fn vaultwarden_css() -> EtagCached<String> {
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS // Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
let css_options = json!({ let css_options = json!({
"custom_roles_enabled": custom_roles_enabled(),
"email_change_allowed": CONFIG.email_change_allowed(), "email_change_allowed": CONFIG.email_change_allowed(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(), "emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true, "load_user_scss": true,
+6
View File
@@ -632,6 +632,9 @@ impl<'r> FromRequest<'r> for Host {
pub struct ClientHeaders { pub struct ClientHeaders {
pub device_type: i32, pub device_type: i32,
pub ip: ClientIp, pub ip: ClientIp,
/// `Bitwarden-Client-Version`, `None` when missing or not a version. Unlike the [`ClientVersion`] guard, this
/// doesn't log an error, as not every client sends it.
pub client_version: Option<semver::Version>,
} }
#[rocket::async_trait] #[rocket::async_trait]
@@ -648,10 +651,13 @@ impl<'r> FromRequest<'r> for ClientHeaders {
.get_one("device-type") .get_one("device-type")
.and_then(|d| d.parse().ok()) .and_then(|d| d.parse().ok())
.unwrap_or(DeviceType::UnknownBrowser as i32); .unwrap_or(DeviceType::UnknownBrowser as i32);
let client_version =
request.headers().get_one("Bitwarden-Client-Version").and_then(|v| semver::Version::parse(v).ok());
Outcome::Success(ClientHeaders { Outcome::Success(ClientHeaders {
device_type, device_type,
ip, ip,
client_version,
}) })
} }
} }
+6
View File
@@ -817,6 +817,10 @@ make_config! {
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
org_groups_enabled: bool, false, def, false; org_groups_enabled: bool, false, def, false;
/// Enable Custom roles (EXPERIMENTAL!) (Know the risks!) |> Enables the in-development Custom member role with granular permissions for organizations.
/// Only the permissions this version enforces can be granted. Disabling it again keeps the stored Custom roles, but they grant nothing until it is re-enabled.
experimental_custom_roles: bool, false, def, false;
/// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000.
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
increase_note_size_limit: bool, true, def, false; increase_note_size_limit: bool, true, def, false;
@@ -1508,6 +1512,8 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Key Management Team // Key Management Team
"biometrics-sdk-ipc", "biometrics-sdk-ipc",
"windows-native-credential-sync", "windows-native-credential-sync",
"enable-account-encryption-v2-jit-password-registration",
"pm-27278-v2-password-registration",
// Mobile Team // Mobile Team
"pm-34171-card-scanner", "pm-34171-card-scanner",
// Platform Team // Platform Team
+71
View File
@@ -534,3 +534,74 @@ mod postgresql_migrations {
Ok(()) Ok(())
} }
} }
/// A throwaway SQLite database with the real schema, for tests that need to run queries.
#[cfg(all(test, sqlite))]
pub mod test_db {
use super::sqlite_migrations::MIGRATIONS;
use diesel::connection::SimpleConnection;
use diesel_migrations::MigrationHarness;
#[test]
fn custom_role_migration_preserves_memberships_and_guards_downgrade() {
use super::schema::users_organizations::dsl::*;
use diesel::{migration::MigrationSource, prelude::*};
let mut conn = SqliteConnection::establish(":memory:").unwrap();
for migration in conn.pending_migrations(MIGRATIONS).unwrap() {
if migration.name().version().to_string() == "20261010120000" {
break;
}
conn.run_migration(&migration).unwrap();
}
conn.batch_execute(
"PRAGMA foreign_keys=OFF;
INSERT INTO organizations (uuid,name,billing_email) VALUES ('org','Org','a@b');
INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES
('0','0','org',TRUE,'key',2,0), ('1','1','org',TRUE,'key',2,1),
('2','2','org',FALSE,'key',1,2), ('3','3','org',FALSE,'key',2,3), ('4','4','org',TRUE,'key',2,3);",
)
.unwrap();
let rows = |conn: &mut SqliteConnection| {
users_organizations
.select((uuid, user_uuid, org_uuid, access_all, akey, status, atype))
.order(uuid)
.load::<(String, String, String, bool, String, i32, i32)>(conn)
.unwrap()
};
let before = rows(&mut conn);
conn.run_pending_migrations(MIGRATIONS).unwrap();
assert_eq!(rows(&mut conn), before);
let grants = |conn: &mut SqliteConnection| {
diesel::select(diesel::dsl::sql::<diesel::sql_types::BigInt>(
"(SELECT COUNT(*) FROM users_organizations WHERE manage_users OR manage_groups OR manage_policies
OR create_new_collections OR edit_any_collection OR delete_any_collection
OR access_event_logs OR access_import_export OR access_reports)",
))
.get_result::<i64>(conn)
.unwrap()
};
assert_eq!(grants(&mut conn), 0);
let migration = MigrationSource::<diesel::sqlite::Sqlite>::migrations(&MIGRATIONS)
.unwrap()
.into_iter()
.find(|m| m.name().version().to_string() == "20261010120000")
.unwrap();
for member_status in [1, 2] {
conn.batch_execute(&format!(
"UPDATE users_organizations SET atype=4,manage_users=TRUE,status={member_status} WHERE uuid='2'"
))
.unwrap();
let custom = rows(&mut conn);
assert!(conn.revert_migration(&*migration).is_err());
assert_eq!(rows(&mut conn), custom);
assert!(users_organizations.filter(uuid.eq("2")).select(manage_users).first::<bool>(&mut conn).unwrap());
}
conn.batch_execute("UPDATE users_organizations SET atype=2,manage_users=FALSE,status=1 WHERE uuid='2'")
.unwrap();
conn.revert_migration(&*migration).unwrap();
assert!(diesel::sql_query("SELECT manage_users FROM users_organizations").execute(&mut conn).is_err());
conn.run_pending_migrations(MIGRATIONS).unwrap();
assert_eq!(grants(&mut conn), 0);
assert_eq!(rows(&mut conn), before);
}
}
+114 -67
View File
@@ -19,13 +19,13 @@ use crate::{
}, },
}, },
error::MapResult, error::MapResult,
util::LowerCase, util::{LowerCase, convert_json_key_lcase_first},
}; };
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
MembershipStatus, MembershipType, OrganizationId, User, UserId, MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -63,6 +63,18 @@ pub struct Cipher {
pub reprompt: Option<i32>, pub reprompt: Option<i32>,
} }
/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Vault/Entities/Cipher.cs#L36-L41>
pub fn is_data_blob_encrypted(data: &str) -> bool {
serde_json::from_str::<Value>(data).is_ok_and(|d| is_blob_value(&d))
}
/// [`is_data_blob_encrypted`] for `data` that was already parsed.
fn is_blob_value(data: &Value) -> bool {
data.get("format_version").is_some()
}
pub enum RepromptType { pub enum RepromptType {
None = 0, None = 0,
Password = 1, Password = 1,
@@ -70,7 +82,8 @@ pub enum RepromptType {
/// Local methods /// Local methods
impl Cipher { impl Cipher {
pub fn new(atype: i32, name: String) -> Self { /// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
pub fn new(atype: i32) -> Self {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
Self { Self {
@@ -84,7 +97,7 @@ impl Cipher {
key: None, key: None,
atype, atype,
name, name: String::new(),
notes: None, notes: None,
fields: None, fields: None,
@@ -110,6 +123,16 @@ impl Cipher {
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap()); .insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
} }
let is_blob = cipher.is_blob();
if let Err(e) = cipher.validate_content(is_blob) {
validation_errors
.insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap());
}
if let Err(e) = cipher.validate_type_data(is_blob) {
validation_errors
.insert(format!("Ciphers[{index}].Type"), serde_json::to_value([e.message()]).unwrap());
}
// Validate the password history if it contains `null` values and if so, return a warning // Validate the password history if it contains `null` values and if so, return a warning
if let Some(Value::Array(password_history)) = &cipher.password_history { if let Some(Value::Array(password_history)) = &cipher.password_history {
for pwh in password_history { for pwh in password_history {
@@ -154,6 +177,10 @@ impl Cipher {
) -> Result<Value, crate::Error> { ) -> Result<Value, crate::Error> {
use crate::util::{format_date, validate_and_format_date}; use crate::util::{format_date, validate_and_format_date};
// Parsed once, since `data` can be large and this runs for every cipher in a sync
let type_data = serde_json::from_str::<Value>(&self.data);
let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value);
let mut attachments_json: Value = Value::Null; let mut attachments_json: Value = Value::Null;
if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid) if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
@@ -250,63 +277,9 @@ impl Cipher {
}) })
.unwrap_or_default(); .unwrap_or_default();
// Get the type_data or a default to an empty json object '{}'. // Like upstream, fields and password history stored as NULL are sent as null, not `[]`
// If not passing an empty object, mobile clients will crash. let fields_json = self.fields.is_some().then_some(fields_json);
let mut type_data_json = serde_json::from_str::<LowerCase<Value>>(&self.data) let password_history_json = self.password_history.is_some().then_some(password_history_json);
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
.map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data);
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data { let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
@@ -403,10 +376,84 @@ impl Cipher {
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
}; };
json_object[key] = type_data_json; if is_blob_encrypted {
// Like upstream, sent as-is with the structured fields null
json_object["data"] = json!(self.data);
json_object["name"] = Value::Null;
} else {
json_object[key] = self.legacy_type_data_json(type_data);
}
Ok(json_object) Ok(json_object)
} }
/// The per-type data of a legacy cipher, with fixups for values known to break clients.
fn legacy_type_data_json(&self, type_data: Result<Value, serde_json::Error>) -> Value {
use crate::util::validate_and_format_date;
// Get the type_data or a default to an empty json object '{}'.
// If not passing an empty object, mobile clients will crash.
let mut type_data_json = if let Ok(data @ Value::Object(_)) = type_data {
convert_json_key_lcase_first(data)
} else {
warn!("Error parsing data field for {}", self.uuid);
Value::Object(serde_json::Map::new())
};
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
type_data_json
}
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> { pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
let mut user_uuids = Vec::new(); let mut user_uuids = Vec::new();
match self.user_uuid { match self.user_uuid {
@@ -872,7 +919,7 @@ impl Cipher {
if !visible_only { if !visible_only {
query = query.or_filter( query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
); );
} }
@@ -907,7 +954,7 @@ impl Cipher {
if !visible_only { if !visible_only {
query = query.or_filter( query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
); );
} }
@@ -1111,7 +1158,7 @@ impl Cipher {
.or(collections_groups::collections_uuid .or(collections_groups::collections_uuid
.is_not_null() // Access via groups .is_not_null() // Access via groups
.and(collections_groups::read_only.eq(false))) .and(collections_groups::read_only.eq(false)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .load::<CollectionId>(conn)
@@ -1140,7 +1187,7 @@ impl Cipher {
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))) .and(users_collections::read_only.eq(false)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .load::<CollectionId>(conn)
@@ -1182,7 +1229,7 @@ impl Cipher {
) )
.or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection
.or_filter(users_organizations::access_all.eq(true)) // User has access all .or_filter(users_organizations::access_all.eq(true)) // User has access all
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner .or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
.or_filter(groups::access_all.eq(true)) //Access via group .or_filter(groups::access_all.eq(true)) //Access via group
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
+7 -7
View File
@@ -18,7 +18,7 @@ use macros::UuidFromParam;
use super::{ use super::{
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
User, UserId, User, UserId, organization::ORG_ADMIN_ATYPES,
}; };
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
@@ -375,7 +375,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
) )
.or( .or(
@@ -412,7 +412,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
)) ))
.select(collections::all_columns) .select(collections::all_columns)
@@ -455,7 +455,7 @@ impl Collection {
) )
.filter( .filter(
users_organizations::atype users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_organizations::access_all.eq(true)) // access_all via membership
.or(users_collections::collection_uuid .or(users_collections::collection_uuid
.eq(&self.uuid) // write access given to collection .eq(&self.uuid) // write access given to collection
@@ -489,7 +489,7 @@ impl Collection {
) )
.filter( .filter(
users_organizations::atype users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_organizations::access_all.eq(true)) // access_all via membership
.or(users_collections::collection_uuid .or(users_collections::collection_uuid
.eq(&self.uuid) // write access given to collection .eq(&self.uuid) // write access given to collection
@@ -540,7 +540,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
) )
.or( .or(
@@ -600,7 +600,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
) )
.or( .or(
+5 -3
View File
@@ -17,11 +17,12 @@ mod two_factor;
mod two_factor_duo_context; mod two_factor_duo_context;
mod two_factor_incomplete; mod two_factor_incomplete;
mod user; mod user;
mod user_signature_key_pair;
pub use self::archive::Archive; pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId}; pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId}; pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{ pub use self::emergency_access::{
@@ -33,8 +34,8 @@ pub use self::folder::{Folder, FolderCipher, FolderId};
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
pub use self::organization::{ pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization,
OrganizationId, OrganizationApiKey, OrganizationId, custom_roles_enabled,
}; };
pub use self::send::{Send, SendFileId, SendId, SendType}; pub use self::send::{Send, SendFileId, SendId, SendType};
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
@@ -42,3 +43,4 @@ pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::two_factor_incomplete::TwoFactorIncomplete;
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException}; pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException};
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
+319 -24
View File
@@ -1,4 +1,7 @@
use std::cmp::Ordering; use std::{
cmp::Ordering,
collections::{HashMap, HashSet},
};
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
@@ -21,8 +24,8 @@ use crate::{
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
TwoFactor, User, UserId, OrgPolicyType, TwoFactor, User, UserId,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -41,6 +44,7 @@ pub struct Organization {
#[diesel(table_name = users_organizations)] #[diesel(table_name = users_organizations)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))] #[diesel(primary_key(uuid))]
#[expect(clippy::struct_excessive_bools, reason = "Each Custom role permission is a column of its own")]
pub struct Membership { pub struct Membership {
pub uuid: MembershipId, pub uuid: MembershipId,
pub user_uuid: UserId, pub user_uuid: UserId,
@@ -54,6 +58,17 @@ pub struct Membership {
pub atype: i32, pub atype: i32,
pub reset_password_key: Option<String>, pub reset_password_key: Option<String>,
pub external_id: Option<String>, pub external_id: Option<String>,
// The permissions of a Custom role, only accessed through `CustomPermission`.
manage_users: bool,
manage_groups: bool,
manage_policies: bool,
create_new_collections: bool,
edit_any_collection: bool,
delete_any_collection: bool,
access_event_logs: bool,
access_import_export: bool,
access_reports: bool,
} }
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -89,42 +104,68 @@ impl MembershipStatus {
} }
} }
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] #[derive(Copy, Clone, Debug, PartialEq, Eq, num_derive::FromPrimitive)]
pub enum MembershipType { pub enum MembershipType {
Owner = 0, Owner = 0,
Admin = 1, Admin = 1,
User = 2, User = 2,
// The legacy Manager role. The Bitwarden clients no longer know it and are shown a Custom role instead.
Manager = 3, Manager = 3,
// Only granted while EXPERIMENTAL_CUSTOM_ROLES is enabled.
Custom = 4,
} }
impl MembershipType { impl MembershipType {
pub fn from_str(s: &str) -> Option<Self> { pub fn from_str(s: &str) -> Option<Self> {
#[expect(
clippy::match_same_arms,
reason = "Specifically define `4|Custom` since this is a hack, not a default"
)]
match s { match s {
"0" | "Owner" => Some(MembershipType::Owner), "0" | "Owner" => Some(MembershipType::Owner),
"1" | "Admin" => Some(MembershipType::Admin), "1" | "Admin" => Some(MembershipType::Admin),
"2" | "User" => Some(MembershipType::User), "2" | "User" => Some(MembershipType::User),
"3" | "Manager" => Some(MembershipType::Manager), "3" | "Manager" => Some(MembershipType::Manager),
// HACK: We convert the custom role to a manager role "4" | "Custom" => Some(MembershipType::Custom),
"4" | "Custom" => Some(MembershipType::Manager),
_ => None, _ => None,
} }
} }
/// The role a member invite or edit from the Bitwarden clients asks for, given the member's current
/// role (`None` for an invite).
///
/// The clients no longer know the legacy Manager role and are shown a Custom role in its place, so a
/// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled. An existing
/// Manager or Custom member keeps their role when the request repeats it, so an unrelated edit never
/// turns one into the other.
pub fn requested(
requested: MembershipType,
current: Option<&Membership>,
custom_roles_enabled: bool,
) -> MembershipType {
if requested != MembershipType::Custom {
return requested;
}
match current {
Some(member) if member.has_type(MembershipType::Custom) => MembershipType::Custom,
Some(member) if member.has_type(MembershipType::Manager) => MembershipType::Manager,
_ if custom_roles_enabled => MembershipType::Custom,
_ => MembershipType::Manager,
}
}
/// The authority of a role, which is what roles are compared by. A Custom member gets its authority from
/// its permissions, never from its role, so the role itself ranks right above User and below Manager.
const fn rank(self) -> u8 {
match self {
MembershipType::User => 0,
MembershipType::Custom => 1,
MembershipType::Manager => 2,
MembershipType::Admin => 3,
MembershipType::Owner => 4,
}
}
} }
impl Ord for MembershipType { impl Ord for MembershipType {
fn cmp(&self, other: &MembershipType) -> Ordering { fn cmp(&self, other: &MembershipType) -> Ordering {
// For easy comparison, map each variant to an access level (where 0 is lowest). self.rank().cmp(&other.rank())
const ACCESS_LEVEL: [i32; 4] = [
3, // Owner
2, // Admin
0, // User
1, // Manager && Custom
];
ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize])
} }
} }
@@ -180,6 +221,91 @@ impl PartialOrd<MembershipType> for i32 {
} }
} }
/// The granular permissions of the Custom role.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CustomPermission {
ManageUsers,
ManageGroups,
ManagePolicies,
CreateNewCollections,
EditAnyCollection,
DeleteAnyCollection,
AccessEventLogs,
AccessImportExport,
AccessReports,
}
/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored
/// grant of any other one grants nothing. They are implemented one at a time, so this grows with them.
const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[];
impl CustomPermission {
pub const ALL: [CustomPermission; 9] = [
CustomPermission::ManageUsers,
CustomPermission::ManageGroups,
CustomPermission::ManagePolicies,
CustomPermission::CreateNewCollections,
CustomPermission::EditAnyCollection,
CustomPermission::DeleteAnyCollection,
CustomPermission::AccessEventLogs,
CustomPermission::AccessImportExport,
CustomPermission::AccessReports,
];
/// The key of the permission in the `permissions` object of the Bitwarden clients.
pub const fn json_key(self) -> &'static str {
match self {
CustomPermission::ManageUsers => "manageUsers",
CustomPermission::ManageGroups => "manageGroups",
CustomPermission::ManagePolicies => "managePolicies",
CustomPermission::CreateNewCollections => "createNewCollections",
CustomPermission::EditAnyCollection => "editAnyCollection",
CustomPermission::DeleteAnyCollection => "deleteAnyCollection",
CustomPermission::AccessEventLogs => "accessEventLogs",
CustomPermission::AccessImportExport => "accessImportExport",
CustomPermission::AccessReports => "accessReports",
}
}
pub fn is_supported(self) -> bool {
SUPPORTED_CUSTOM_PERMISSIONS.contains(&self)
}
/// The permissions a member invite or edit grants a Custom role.
///
/// A permission is only granted when it is set to `true`, and anything but a boolean is rejected: this
/// replaces the member's permissions, so a malformed value must not silently remove one. Granting a
/// permission this version does not enforce is rejected as well. The clients also send permissions
/// Vaultwarden does not know at all, like `manageSso`, those are ignored.
pub fn requested(permissions: &HashMap<String, Value>) -> Result<HashSet<CustomPermission>, crate::Error> {
let mut granted = HashSet::new();
for permission in CustomPermission::ALL {
match permissions.get(permission.json_key()) {
None | Some(Value::Bool(false)) => {}
Some(Value::Bool(true)) if permission.is_supported() => {
granted.insert(permission);
}
Some(Value::Bool(true)) => {
err!(format!("The Custom role permission '{}' is not supported yet", permission.json_key()))
}
Some(_) => {
err!(format!("The Custom role permission '{}' must be true or false", permission.json_key()))
}
}
}
Ok(granted)
}
}
/// The stored roles with authority over the whole organization. Queries match them by value, comparing
/// `atype <= Admin` would also match any unknown value below it.
pub(super) const ORG_ADMIN_ATYPES: [i32; 2] = [MembershipType::Owner as i32, MembershipType::Admin as i32];
/// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise.
pub fn custom_roles_enabled() -> bool {
CONFIG.experimental_custom_roles()
}
/// Local methods /// Local methods
impl Organization { impl Organization {
pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self { pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self {
@@ -271,9 +397,92 @@ impl Membership {
atype: MembershipType::User as i32, atype: MembershipType::User as i32,
reset_password_key: None, reset_password_key: None,
external_id: None, external_id: None,
manage_users: false,
manage_groups: false,
manage_policies: false,
create_new_collections: false,
edit_any_collection: false,
delete_any_collection: false,
access_event_logs: false,
access_import_export: false,
access_reports: false,
} }
} }
fn custom_permission_mut(&mut self, permission: CustomPermission) -> &mut bool {
match permission {
CustomPermission::ManageUsers => &mut self.manage_users,
CustomPermission::ManageGroups => &mut self.manage_groups,
CustomPermission::ManagePolicies => &mut self.manage_policies,
CustomPermission::CreateNewCollections => &mut self.create_new_collections,
CustomPermission::EditAnyCollection => &mut self.edit_any_collection,
CustomPermission::DeleteAnyCollection => &mut self.delete_any_collection,
CustomPermission::AccessEventLogs => &mut self.access_event_logs,
CustomPermission::AccessImportExport => &mut self.access_import_export,
CustomPermission::AccessReports => &mut self.access_reports,
}
}
/// Change the role. Nothing the old role carried survives a role change: the Custom role permissions are
/// dropped, so changing the role back later starts from none. A Custom role never keeps the legacy
/// `access_all` flag, its reach only comes from its permissions.
pub fn set_type(&mut self, new_type: MembershipType) {
if self.atype != new_type as i32 {
for permission in CustomPermission::ALL {
*self.custom_permission_mut(permission) = false;
}
}
if new_type == MembershipType::Custom {
self.access_all = false;
}
self.atype = new_type as i32;
}
/// Grant this Custom member exactly `permissions` out of those this version enforces. A stored grant of a
/// permission this version does not enforce is kept as it is: it grants nothing here.
pub fn set_custom_permissions(&mut self, permissions: &HashSet<CustomPermission>) {
for permission in CustomPermission::ALL.into_iter().filter(|p| p.is_supported()) {
*self.custom_permission_mut(permission) = permissions.contains(&permission);
}
}
/// Why this legacy Manager can't become a Custom member without losing what they may do now, if they can't. A
/// conversion must not silently change their access, and a Custom role can't keep access to all collections
/// (which also puts every collection in the Manager's own vault), nor manage any collection yet.
pub async fn custom_role_conversion_loss(&self, conn: &DbConn) -> Option<&'static str> {
if !self.has_type(MembershipType::Manager) {
return None;
}
if self.access_all {
return Some(
"This Manager has access to all collections, which a Custom member can't have: the collections they \
aren't assigned to would disappear from their vault. Uncheck Manage all collections for them first, \
or keep the Manager role.",
);
}
// Whatever their status, the collections they manage directly, through a group or through a group with
// access to all collections
let mut manages = GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await
|| CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
.await
.iter()
.any(|assignment| assignment.manage);
for group_user in GroupUser::find_by_member(&self.uuid, conn).await {
if manages {
break;
}
manages = CollectionGroup::find_by_group(&group_user.groups_uuid, &self.org_uuid, conn)
.await
.iter()
.any(|assignment| assignment.manage);
}
manages.then_some(
"This Manager manages collections, with the manage permission or through a group with access to all \
collections, which a Custom member can't do yet. Remove that first, or keep the Manager role.",
)
}
pub fn restore(&mut self) -> bool { pub fn restore(&mut self) -> bool {
if self.status < MembershipStatus::Invited as i32 { if self.status < MembershipStatus::Invited as i32 {
self.status += ACTIVATE_REVOKE_DIFF; self.status += ACTIVATE_REVOKE_DIFF;
@@ -712,6 +921,12 @@ impl Membership {
} }
pub async fn save(&self, conn: &DbConn) -> EmptyResult { pub async fn save(&self, conn: &DbConn) -> EmptyResult {
// The legacy `access_all` flag reaches every collection, which a Custom role may only do through its
// permissions, so it must never be stored on one. `set_type` takes care of this.
if self.has_type(MembershipType::Custom) && self.access_all {
err!("A Custom role membership can't have access to all collections")
}
User::update_uuid_revision(&self.user_uuid, conn).await; User::update_uuid_revision(&self.user_uuid, conn).await;
db_run! { conn: db_run! { conn:
@@ -1228,13 +1443,93 @@ pub struct OrgApiKeyId(String);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use MembershipType::{Admin, Custom, Manager, Owner, User};
fn member(role: MembershipType) -> Membership {
let mut member = Membership::new("user".into(), "org".into(), None);
member.atype = role as i32;
member.status = MembershipStatus::Confirmed as i32;
member
}
#[test] #[test]
#[allow(non_snake_case)] fn membership_type_authority() {
fn partial_cmp_MembershipType() { assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User);
assert!(MembershipType::Owner > MembershipType::Admin); assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin);
assert!(MembershipType::Admin > MembershipType::Manager); for raw in [99, -1] {
assert!(MembershipType::Manager > MembershipType::User); assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true));
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); }
for (value, expected) in [
("3", Some(Manager)),
("Manager", Some(Manager)),
("4", Some(Custom)),
("Custom", Some(Custom)),
("5", None),
] {
assert_eq!(MembershipType::from_str(value), expected);
}
}
#[test]
fn requested_membership_type() {
for (current, disabled, enabled) in [
(None, Manager, Custom),
(Some(User), Manager, Custom),
(Some(Manager), Manager, Manager),
(Some(Custom), Custom, Custom),
] {
let current = current.map(member);
for (flag, expected) in [(false, disabled), (true, enabled)] {
assert_eq!(MembershipType::requested(Custom, current.as_ref(), flag), expected);
}
}
assert_eq!(MembershipType::requested(User, Some(&member(Custom)), true), User);
assert_eq!(MembershipType::requested(User, Some(&member(Manager)), true), User);
for requested in [Owner, Admin, Manager] {
assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), true), requested);
}
}
#[test]
fn custom_permission_requests() {
let ignored = serde_json::from_value(json!({"manageUsers":false,"manageSso":true})).unwrap();
assert!(CustomPermission::requested(&ignored).unwrap().is_empty());
for permission in CustomPermission::ALL {
for value in [json!(true), json!(null), json!("true"), json!(1), json!([true])] {
let request = HashMap::from([(permission.json_key().to_owned(), value.clone())]);
let result = CustomPermission::requested(&request);
if value == json!(true) && SUPPORTED_CUSTOM_PERMISSIONS.contains(&permission) {
assert_eq!(result.unwrap(), HashSet::from([permission]));
} else {
assert!(result.is_err(), "{permission:?}: {value}");
}
}
}
assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, []);
}
#[test]
fn role_changes_drop_custom_permissions() {
for (from, to) in [
(Custom, Owner),
(Custom, Admin),
(Custom, Manager),
(Custom, User),
(User, Custom),
(Manager, Custom),
(Custom, Custom),
(Admin, User),
] {
let mut member = member(from);
member.access_all = true;
for permission in CustomPermission::ALL {
*member.custom_permission_mut(permission) = true;
}
member.set_type(to);
for permission in CustomPermission::ALL {
assert_eq!(*member.custom_permission_mut(permission), from == to, "{from:?} -> {to:?}");
}
assert_eq!(member.access_all, to != Custom);
}
} }
} }
+53 -6
View File
@@ -20,7 +20,7 @@ use macros::UuidFromParam;
use super::{ use super::{
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
TwoFactorIncomplete, TwoFactorIncomplete, UserSignatureKeyPair,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
@@ -72,6 +72,11 @@ pub struct User {
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore. pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
pub key_id: Option<KeyId>, pub key_id: Option<KeyId>,
// The v2 state: all set, with a `user_signature_key_pairs` row, or none, see `User::is_v2`
pub signed_public_key: Option<String>,
pub security_state: Option<String>,
pub security_version: Option<i32>,
} }
#[derive(Identifiable, Queryable, Insertable)] #[derive(Identifiable, Queryable, Insertable)]
@@ -159,9 +164,17 @@ impl User {
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
key_id: None, key_id: None,
signed_public_key: None,
security_state: None,
security_version: None,
} }
} }
pub fn is_v2(&self) -> bool {
self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some()
}
pub fn check_valid_password(&self, password: &str) -> bool { pub fn check_valid_password(&self, password: &str) -> bool {
crypto::verify_password_hash( crypto::verify_password_hash(
password.as_bytes(), password.as_bytes(),
@@ -266,18 +279,37 @@ impl User {
!CONFIG.mail_enabled() || self.verified_at.is_some() !CONFIG.mail_enabled() || self.verified_at.is_some()
} }
async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option<UserSignatureKeyPair> {
if !self.is_v2() {
return None;
}
UserSignatureKeyPair::find_by_user(&self.uuid, conn).await
}
/// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair /// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair
pub fn account_keys_json(&self) -> Value { pub async fn account_keys_json(&self, conn: &DbConn) -> Value {
if self.private_key.is_some() { if self.private_key.is_some() {
let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (
json!(self.signed_public_key),
key_pair.to_json(),
json!({
"securityState": self.security_state,
"securityVersion": self.security_version,
}),
),
None => (Value::Null, Value::Null, Value::Null),
};
json!({ json!({
"publicKeyEncryptionKeyPair": { "publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key, "wrappedPrivateKey": self.private_key,
"publicKey": self.public_key, "publicKey": self.public_key,
"signedPublicKey": null, "signedPublicKey": signed_public_key,
"object": "publicKeyEncryptionKeyPair", "object": "publicKeyEncryptionKeyPair",
}, },
"securityState": null, "securityState": security_state,
"signatureKeyPair": null, "signatureKeyPair": signature_key_pair,
"object": "privateKeys" "object": "privateKeys"
}) })
} else { } else {
@@ -285,6 +317,20 @@ impl User {
} }
} }
pub async fn public_keys_json(&self, conn: &DbConn) -> Value {
let (signed_public_key, verifying_key) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (json!(self.signed_public_key), json!(key_pair.verifying_key)),
None => (Value::Null, Value::Null),
};
json!({
"publicKey": self.public_key,
"signedPublicKey": signed_public_key,
"verifyingKey": verifying_key,
"object": "publicKeys"
})
}
pub async fn to_json(&self, conn: &DbConn) -> Value { pub async fn to_json(&self, conn: &DbConn) -> Value {
let mut orgs_json = Vec::new(); let mut orgs_json = Vec::new();
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await { for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
@@ -305,7 +351,7 @@ impl User {
UserStatus::Enabled UserStatus::Enabled
}; };
let account_keys = self.account_keys_json(); let account_keys = self.account_keys_json(conn).await;
json!({ json!({
"_status": status as i32, "_status": status as i32,
@@ -382,6 +428,7 @@ impl User {
Device::delete_all_by_user(&self.uuid, conn).await?; Device::delete_all_by_user(&self.uuid, conn).await?;
TwoFactor::delete_all_by_user(&self.uuid, conn).await?; TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?; TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
UserSignatureKeyPair::delete_all_by_user(&self.uuid, conn).await?;
Invitation::take(&self.email, conn).await; // Delete invitation if any Invitation::take(&self.email, conn).await; // Delete invitation if any
conn.run(move |conn| { conn.run(move |conn| {
+132
View File
@@ -0,0 +1,132 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use serde_json::Value;
use crate::{
api::EmptyResult,
db::{DbConn, schema::user_signature_key_pairs},
error::MapResult,
util::get_uuid,
};
use super::UserId;
/// A user's signature key pair, part of the v2 state, in its own table like upstream.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Sql/dbo/KeyManagement/Tables/UserSignatureKeyPair.sql#L1-L15>
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
#[diesel(table_name = user_signature_key_pairs)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
pub struct UserSignatureKeyPair {
pub uuid: UserSignatureKeyPairId,
pub user_uuid: UserId,
pub signature_algorithm: i32,
/// The signing (private) key, wrapped by the user key.
pub signing_key: String,
/// The COSE-encoded public verifying key.
pub verifying_key: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Enums/SignatureAlgorithm.cs#L6-L10>
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SignatureAlgorithm {
Ed25519 = 0,
MlDsa44 = 1,
}
impl SignatureAlgorithm {
pub fn parse(algorithm: &str) -> Option<Self> {
match algorithm {
"ed25519" => Some(Self::Ed25519),
"mldsa44" => Some(Self::MlDsa44),
_ => None,
}
}
}
/// Local methods
impl UserSignatureKeyPair {
pub fn new(
user_uuid: UserId,
signature_algorithm: SignatureAlgorithm,
signing_key: String,
verifying_key: String,
) -> Self {
let now = Utc::now().naive_utc();
Self {
uuid: UserSignatureKeyPairId(get_uuid()),
user_uuid,
signature_algorithm: signature_algorithm as i32,
signing_key,
verifying_key,
created_at: now,
updated_at: now,
}
}
pub fn to_json(&self) -> Value {
json!({
"wrappedSigningKey": self.signing_key,
"verifyingKey": self.verifying_key,
"object": "signatureKeyPair",
})
}
}
/// Database methods
impl UserSignatureKeyPair {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
postgresql, sqlite {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(user_signature_key_pairs::user_uuid)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
}
}
/// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`.
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| {
user_signature_key_pairs::table
.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))
.first::<Self>(conn)
.ok()
})
.await
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting user signature key pairs")
})
.await
}
}
#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserSignatureKeyPairId(String);
+26
View File
@@ -219,6 +219,21 @@ table! {
avatar_color -> Nullable<Text>, avatar_color -> Nullable<Text>,
external_id -> Nullable<Text>, external_id -> Nullable<Text>,
key_id -> Nullable<Text>, key_id -> Nullable<Text>,
signed_public_key -> Nullable<Text>,
security_state -> Nullable<Text>,
security_version -> Nullable<Integer>,
}
}
table! {
user_signature_key_pairs (uuid) {
uuid -> Text,
user_uuid -> Text,
signature_algorithm -> Integer,
signing_key -> Text,
verifying_key -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
} }
} }
@@ -244,6 +259,15 @@ table! {
atype -> Integer, atype -> Integer,
reset_password_key -> Nullable<Text>, reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>, external_id -> Nullable<Text>,
manage_users -> Bool,
manage_groups -> Bool,
manage_policies -> Bool,
create_new_collections -> Bool,
edit_any_collection -> Bool,
delete_any_collection -> Bool,
access_event_logs -> Bool,
access_import_export -> Bool,
access_reports -> Bool,
} }
} }
@@ -384,6 +408,7 @@ joinable!(collections_groups -> groups (groups_uuid));
joinable!(event -> users_organizations (uuid)); joinable!(event -> users_organizations (uuid));
joinable!(auth_requests -> users (user_uuid)); joinable!(auth_requests -> users (user_uuid));
joinable!(sso_users -> users (user_uuid)); joinable!(sso_users -> users (user_uuid));
joinable!(user_signature_key_pairs -> users (user_uuid));
allow_tables_to_appear_in_same_query!( allow_tables_to_appear_in_same_query!(
archives, archives,
@@ -410,4 +435,5 @@ allow_tables_to_appear_in_same_query!(
collections_groups, collections_groups,
event, event,
auth_requests, auth_requests,
user_signature_key_pairs,
); );
+12 -4
View File
@@ -173,10 +173,14 @@ const ORG_TYPES = {
"name": "User", "name": "User",
"bg": "blue" "bg": "blue"
}, },
"4": { "3": {
"name": "Manager", "name": "Manager",
"bg": "green" "bg": "green"
}, },
"4": {
"name": "Custom",
"bg": "teal"
},
}; };
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
@@ -187,15 +191,19 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function (event) {
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
// Get org specific values // Get org specific values
const userOrgType = event.relatedTarget.dataset.vwOrgType; const userOrgType = event.relatedTarget.dataset.vwOrgType;
const userOrgTypeName = ORG_TYPES[userOrgType]["name"];
const orgName = event.relatedTarget.dataset.vwOrgName; const orgName = event.relatedTarget.dataset.vwOrgName;
const orgUuid = event.relatedTarget.dataset.vwOrgUuid; const orgUuid = event.relatedTarget.dataset.vwOrgUuid;
document.getElementById("userOrgTypeForm").reset();
document.getElementById("userOrgTypeDialogOrgName").textContent = orgName; document.getElementById("userOrgTypeDialogOrgName").textContent = orgName;
document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail; document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail;
document.getElementById("userOrgTypeUserUuid").value = userUuid; document.getElementById("userOrgTypeUserUuid").value = userUuid;
document.getElementById("userOrgTypeOrgUuid").value = orgUuid; document.getElementById("userOrgTypeOrgUuid").value = orgUuid;
document.getElementById(`userOrgType${userOrgTypeName}`).checked = true; // The Custom role can only be selected while it is enabled
const userOrgTypeRadio = document.getElementById(`userOrgType${ORG_TYPES[userOrgType]?.name}`);
if (userOrgTypeRadio) {
userOrgTypeRadio.checked = true;
}
}, false); }, false);
// Prevent accidental submission of the form with valid elements after the modal has been hidden. // Prevent accidental submission of the form with valid elements after the modal has been hidden.
@@ -222,7 +230,7 @@ function updateUserOrgType(event) {
function initUserTable() { function initUserTable() {
// Color all the org buttons per type // Color all the org buttons per type
document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) { document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) {
const orgType = ORG_TYPES[e.dataset.vwOrgType]; const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? { "name": "Unknown", "bg": "gray" };
e.style.backgroundColor = orgType.bg; e.style.backgroundColor = orgType.bg;
if (orgType.font !== undefined) { if (orgType.font !== undefined) {
e.style.color = orgType.font; e.style.color = orgType.font;
+5
View File
@@ -135,6 +135,11 @@
<div class="radio"> <div class="radio">
<label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label> <label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label>
</div> </div>
{{#if custom_roles_enabled}}
<div class="radio">
<label><input type="radio" value="4" class="form-radio-input" name="user_type" id="userOrgTypeCustom">&nbsp;Custom</label>
</div>
{{/if}}
<div class="radio"> <div class="radio">
<label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label> <label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label>
</div> </div>
@@ -98,10 +98,17 @@ app-security > app-two-factor-setup > form {
} }
/* Hide unsupported Custom Role options */ /* Hide unsupported Custom Role options */
{{#if custom_roles_enabled}}
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input), :is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { :is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
@extend %vw-hide; @extend %vw-hide;
} }
{{else}}
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
@extend %vw-hide;
}
{{/if}}
/* Change collapsed menu icon to Vaultwarden */ /* Change collapsed menu icon to Vaultwarden */
bit-nav-logo bit-nav-item a:before { bit-nav-logo bit-nav-item a:before {
+48 -2
View File
@@ -302,6 +302,20 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC
// Effectively ignores, any static file route, and the alive endpoint // Effectively ignores, any static file route, and the alive endpoint
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
const MAX_LOGGED_QUERY_BYTES: usize = 100;
// Query values can hold secrets, like the access code of a login request, so we don't log them
fn redact_query_values(query: &str) -> String {
query
.split('&')
.map(|param| match param.split_once('=') {
Some((name, _)) => format!("{name}=…"),
None => param.to_owned(),
})
.collect::<Vec<_>>()
.join("&")
}
// When extra is true, we ignore the whitelist above and also print the mounts // When extra is true, we ignore the whitelist above and also print the mounts
pub struct BetterLogging { pub struct BetterLogging {
extra: bool, extra: bool,
@@ -381,8 +395,9 @@ impl Fairing for BetterLogging {
}; };
match uri.query() { match uri.query() {
Some(q) => { Some(q) => {
let q = q.as_str(); let query = redact_query_values(q.as_str());
info!(target: "request", "{method} {}?{}", path.escape_debug(), q[..q.floor_char_boundary(30)].escape_debug()); let query = &query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)];
info!(target: "request", "{method} {}?{}", path.escape_debug(), query.escape_debug());
} }
None => info!(target: "request", "{method} {}", path.escape_debug()), None => info!(target: "request", "{method} {}", path.escape_debug()),
} }
@@ -884,6 +899,12 @@ pub fn parse_experimental_client_feature_flags(
.collect() .collect()
} }
/// Whether the admin enabled this supported client feature flag
pub fn is_client_feature_flag_enabled(flag: &str) -> bool {
parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly)
.contains_key(flag)
}
/// TODO: This is extracted from IpAddr::is_global, which is unstable: /// TODO: This is extracted from IpAddr::is_global, which is unstable:
/// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global /// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global
/// Remove once https://github.com/rust-lang/rust/issues/27709 is merged /// Remove once https://github.com/rust-lang/rust/issues/27709 is merged
@@ -1027,3 +1048,28 @@ mod tests {
}); });
} }
} }
#[cfg(test)]
mod query_log_tests {
use super::{MAX_LOGGED_QUERY_BYTES, redact_query_values};
#[test]
fn test_redact_query_values() {
assert_eq!(redact_query_values("code=SECRET"), "code=…");
assert_eq!(redact_query_values("code=SECRET&other=SECRET"), "code=…&other=…");
assert_eq!(redact_query_values("a=1&b=2&flag"), "a=…&b=…&flag");
assert_eq!(redact_query_values("a=b=SECRET"), "a=…");
assert_eq!(redact_query_values("a=1&&=SECRET"), "a=…&&=…");
assert_eq!(redact_query_values("a=x%26b%3DSECRET"), "a=…");
assert_eq!(redact_query_values("a=1;b=SECRET"), "a=…");
assert_eq!(redact_query_values("a=1&a=SECRET"), "a=…&a=…");
assert_eq!(redact_query_values(""), "");
}
#[test]
fn test_redacted_query_truncates_on_a_char_boundary() {
let name = "a".repeat(MAX_LOGGED_QUERY_BYTES - 1);
let query = redact_query_values(&format!("{name}=SECRET"));
assert_eq!(&query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)], format!("{name}="));
}
}