Browse Source

Merge a6ccf0363c into 061694d0cb

pull/7534/merge
Tom 9 hours ago
committed by GitHub
parent
commit
b73f99b35d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 15
      .env.template
  2. 6
      .typos.toml
  3. 20
      migrations/mysql/2026-07-31-120000_add_trusted_device_encryption/down.sql
  4. 8
      migrations/mysql/2026-07-31-120000_add_trusted_device_encryption/up.sql
  5. 8
      migrations/postgresql/2026-07-31-120000_add_trusted_device_encryption/down.sql
  6. 8
      migrations/postgresql/2026-07-31-120000_add_trusted_device_encryption/up.sql
  7. 8
      migrations/sqlite/2026-07-31-120000_add_trusted_device_encryption/down.sql
  8. 8
      migrations/sqlite/2026-07-31-120000_add_trusted_device_encryption/up.sql
  9. 830
      src/api/core/accounts.rs
  10. 330
      src/api/core/organizations.rs
  11. 3
      src/api/core/sends.rs
  12. 226
      src/api/identity.rs
  13. 9
      src/api/notifications.rs
  14. 15
      src/api/push.rs
  15. 34
      src/auth.rs
  16. 10
      src/config.rs
  17. 272
      src/db/models/auth_request.rs
  18. 301
      src/db/models/device.rs
  19. 2
      src/db/models/event.rs
  20. 2
      src/db/models/mod.rs
  21. 154
      src/db/models/organization.rs
  22. 4
      src/db/schema.rs
  23. 51
      src/mail.rs
  24. 6
      src/static/templates/email/device_approval_requested.hbs
  25. 16
      src/static/templates/email/device_approval_requested.html.hbs
  26. 9
      src/static/templates/email/trusted_device_admin_approval.hbs
  27. 22
      src/static/templates/email/trusted_device_admin_approval.html.hbs
  28. 153
      src/util.rs

15
.env.template

@ -564,6 +564,21 @@
## Log all the tokens, LOG_LEVEL=debug is required ## Log all the tokens, LOG_LEVEL=debug is required
# SSO_DEBUG_TOKENS=false # SSO_DEBUG_TOKENS=false
## Trusted device encryption ("passwordless SSO"), see https://bitwarden.com/help/login-with-sso-trusted-devices/
## After an SSO login the client may keep a copy of the user key on the device, wrapped for a key
## pair that the device generated, so the vault unlocks without a master password. A second device
## is unlocked by approving it from an already trusted one, by asking an administrator of the
## organization (which needs the member enrolled into account recovery), or with the master password.
## WARNING: a user who never sets a master password and then loses every trusted device depends on
## an administrator to get back in, and cannot recover their vault at all without one.
## To turn this off again, clear this setting but leave `SSO_ENABLED` on: users without a master
## password keep receiving their keys while they still have a trusted device, so their client can
## walk them through setting one. Turning off `SSO_ENABLED` instead leaves them no way to log in.
## Answering a device approval needs the "Device approvals" page of the organization settings,
## which a stock web vault does not build. Without it, a member who lost every trusted device is
## recovered through account recovery instead, which works but hands them a new master password.
# SSO_TRUSTED_DEVICE_ENCRYPTION=false
######################## ########################
### MFA/2FA settings ### ### MFA/2FA settings ###
######################## ########################

6
.typos.toml

@ -14,9 +14,9 @@ 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, which covers the type names taken from
"COSEKey", # it as well as the acronym on its own
"COSEAlgorithm", "COSE",
# 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

20
migrations/mysql/2026-07-31-120000_add_trusted_device_encryption/down.sql

@ -0,0 +1,20 @@
-- Creating the index above lets InnoDB drop the index it had made for the `organization_uuid`
-- foreign key, and it then refuses to drop the last index that constraint is left with. Put a
-- single column index back first, unless an earlier revert already left one behind.
SET @restore_fk_index := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE table_schema = DATABASE() AND table_name = 'auth_requests' AND index_name = 'organization_uuid') = 0,
'CREATE INDEX organization_uuid ON auth_requests (organization_uuid)',
'DO 0'
);
PREPARE restore_fk_index FROM @restore_fk_index;
EXECUTE restore_fk_index;
DEALLOCATE PREPARE restore_fk_index;
DROP INDEX auth_requests_creation_date ON auth_requests;
DROP INDEX auth_requests_organization_type ON auth_requests;
ALTER TABLE auth_requests DROP COLUMN atype;
ALTER TABLE devices DROP COLUMN encrypted_private_key;
ALTER TABLE devices DROP COLUMN encrypted_public_key;
ALTER TABLE devices DROP COLUMN encrypted_user_key;

8
migrations/mysql/2026-07-31-120000_add_trusted_device_encryption/up.sql

@ -0,0 +1,8 @@
ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT;
ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT;
ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT;
ALTER TABLE auth_requests ADD COLUMN atype INTEGER NOT NULL DEFAULT 0;
CREATE INDEX auth_requests_organization_type ON auth_requests (organization_uuid, atype, approved);
CREATE INDEX auth_requests_creation_date ON auth_requests (creation_date);

8
migrations/postgresql/2026-07-31-120000_add_trusted_device_encryption/down.sql

@ -0,0 +1,8 @@
DROP INDEX auth_requests_creation_date;
DROP INDEX auth_requests_organization_type;
ALTER TABLE auth_requests DROP COLUMN atype;
ALTER TABLE devices DROP COLUMN encrypted_private_key;
ALTER TABLE devices DROP COLUMN encrypted_public_key;
ALTER TABLE devices DROP COLUMN encrypted_user_key;

8
migrations/postgresql/2026-07-31-120000_add_trusted_device_encryption/up.sql

@ -0,0 +1,8 @@
ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT;
ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT;
ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT;
ALTER TABLE auth_requests ADD COLUMN atype INTEGER NOT NULL DEFAULT 0;
CREATE INDEX auth_requests_organization_type ON auth_requests (organization_uuid, atype, approved);
CREATE INDEX auth_requests_creation_date ON auth_requests (creation_date);

8
migrations/sqlite/2026-07-31-120000_add_trusted_device_encryption/down.sql

@ -0,0 +1,8 @@
DROP INDEX auth_requests_creation_date;
DROP INDEX auth_requests_organization_type;
ALTER TABLE auth_requests DROP COLUMN atype;
ALTER TABLE devices DROP COLUMN encrypted_private_key;
ALTER TABLE devices DROP COLUMN encrypted_public_key;
ALTER TABLE devices DROP COLUMN encrypted_user_key;

8
migrations/sqlite/2026-07-31-120000_add_trusted_device_encryption/up.sql

@ -0,0 +1,8 @@
ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT;
ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT;
ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT;
ALTER TABLE auth_requests ADD COLUMN atype INTEGER NOT NULL DEFAULT 0;
CREATE INDEX auth_requests_organization_type ON auth_requests (organization_uuid, atype, approved);
CREATE INDEX auth_requests_creation_date ON auth_requests (creation_date);

830
src/api/core/accounts.rs

@ -1,4 +1,4 @@
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use chrono::Utc; use chrono::Utc;
use rocket::{ use rocket::{
@ -20,10 +20,10 @@ use crate::{
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{ models::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, AuthRequest, AuthRequestId, AuthRequestType, Cipher, CipherId, Device, DeviceId, DeviceType,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership, DeviceWithAuthRequest, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId,
MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, Membership, MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User,
UserKdfType, UserId, UserKdfType,
}, },
}, },
mail, mail,
@ -45,6 +45,7 @@ pub fn routes() -> Vec<rocket::Route> {
post_keys, post_keys,
post_password, post_password,
post_set_password, post_set_password,
put_update_tde_offboarding_password,
post_kdf, post_kdf,
post_rotatekey, post_rotatekey,
post_user_key, post_user_key,
@ -70,8 +71,15 @@ pub fn routes() -> Vec<rocket::Route> {
put_device_token, put_device_token,
put_clear_device_token, put_clear_device_token,
post_clear_device_token, post_clear_device_token,
put_device_keys,
post_device_keys,
post_device_retrieve_keys,
post_devices_update_trust,
post_devices_untrust,
post_devices_lost_trust,
get_tasks, get_tasks,
post_auth_request, post_auth_request,
post_admin_auth_request,
get_auth_request, get_auth_request,
put_auth_request, put_auth_request,
get_auth_request_response, get_auth_request_response,
@ -442,8 +450,11 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
let data: SetPasswordData = data.into_inner(); let data: SetPasswordData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
if user.private_key.is_some() { // A trusted device account already has its key pair but no master password, and must still be
err!("Account already initialized, cannot set password") // able to add one later, for instance once the server stops offering trusted device encryption.
// What this must never do is hand out a fresh master password for an account that has one.
if !user.password_hash.is_empty() {
err!("Account already has a master password")
} }
// Check against the password hint setting here so if it fails, // Check against the password hint setting here so if it fails,
@ -451,6 +462,19 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
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())?;
// Same reasoning as in `post_keys`: the existing ciphers are encrypted under the existing key
// pair, so an account that has one only gets a password, never new keys.
let keys = match (data.keys, user.private_key.is_some() || user.public_key.is_some()) {
(Some(keys), false) => Some(keys),
(Some(keys), true)
if user.private_key.as_ref() != Some(&keys.encrypted_private_key)
|| user.public_key.as_ref() != Some(&keys.public_key) =>
{
err!("Account already initialized, cannot replace the account keys")
}
_ => None,
};
set_kdf_data(&mut user, &data.kdf)?; set_kdf_data(&mut user, &data.kdf)?;
user.set_password( user.set_password(
@ -463,7 +487,7 @@ 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(keys) = keys {
user.private_key = Some(keys.encrypted_private_key); user.private_key = Some(keys.encrypted_private_key);
user.public_key = Some(keys.public_key); user.public_key = Some(keys.public_key);
} }
@ -500,6 +524,85 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
}))) })))
} }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateTdeOffboardingPasswordData {
new_master_password_hash: String,
/// The user key the account already has, re-wrapped for the master key derived from the new
/// password. The vault is not re-encrypted, so this is the only thing that changes about it.
key: String,
master_password_hint: Option<String>,
}
/// Gives an account that unlocks with a trusted device the master password it needs once the server stops
/// offering trusted devices.
///
/// The endpoint the clients take when a login answered `IsTdeOffboarding`, see `trusted_device_option`.
/// Deliberately not `/accounts/set-password`: the account is fully set up by this point, so the only
/// thing added is a second way to unlock the user key it already has. The account key pair and the vault
/// are left as they are, and unlike `/accounts/keys` nothing here could replace them.
///
/// Upstream keys this on the organization having switched its SSO member decryption away from trusted
/// devices; Vaultwarden configures SSO for the whole server, so the same state is `SSO_ENABLED` without
/// `SSO_TRUSTED_DEVICE_ENCRYPTION`, which is exactly when a login starts answering `IsTdeOffboarding`.
/// https://github.com/bitwarden/server/blob/main/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs
#[put("/accounts/update-tde-offboarding-password", data = "<data>")]
async fn put_update_tde_offboarding_password(
data: Json<UpdateTdeOffboardingPasswordData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
let data = data.into_inner();
let mut user = headers.user;
// Adding a master password to an account that has one is changing it, which is `/accounts/password`
// and asks for the current one first. Without this an authenticated caller could replace the password
// of the account they are on, and a second offboarding call would overwrite the first one's password.
if !user.password_hash.is_empty() {
err!("Account already has a master password")
}
// The way out of trusted devices only exists while the server still takes SSO logins but no
// longer offers trusted devices. A server that still offers them has nothing to offboard from,
// and one without SSO never had the flow at all.
if !CONFIG.sso_enabled() || CONFIG.sso_trusted_device_encryption() {
err!("Trusted device offboarding is not available on this server")
}
// A user key that is not an encrypted string unlocks nothing, and this is the only copy the
// master password can reach. Storing it would leave an account that logs in and then cannot
// open its own vault.
if !crate::util::is_valid_enc_string(&data.key) {
err!("key is not a valid encrypted string")
}
let password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(password_hint.as_ref())?;
// The KDF is left alone: the client derived the master key from the settings the account
// already has, and sends nothing to change them by, as upstream does here.
user.set_password(&data.new_master_password_hash, Some(data.key), true, None, &conn).await?;
user.password_hint = password_hint;
log_user_event(
EventType::UserTdeOffboardingPasswordSet as i32,
&user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
user.save(&conn).await?;
// Upstream logs every session out at this point. The account unlocks a different way from now
// on, so the sessions that were opened against a trusted device do not carry over.
nt.send_logout(&user, None, &conn).await;
Ok(())
}
#[get("/accounts/profile")] #[get("/accounts/profile")]
async fn profile(headers: Headers, conn: DbConn) -> Json<Value> { async fn profile(headers: Headers, conn: DbConn) -> Json<Value> {
Json(headers.user.to_json(&conn).await) Json(headers.user.to_json(&conn).await)
@ -581,6 +684,24 @@ async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> Json
let mut user = headers.user; let mut user = headers.user;
// Replacing the key pair of an initialized account would make every existing cipher undecryptable,
// so only accept it while the account has none yet. The clients call this during account creation,
// including the trusted device flow, where a stale client state could otherwise send us here for an
// account that is already set up. Repeating the same keys stays allowed so a retry does not fail.
if user.private_key.is_some() || user.public_key.is_some() {
if user.private_key.as_ref() != Some(&data.encrypted_private_key)
|| user.public_key.as_ref() != Some(&data.public_key)
{
err!("Account already initialized, cannot replace the account keys")
}
return Ok(Json(json!({
"privateKey": user.private_key,
"publicKey": user.public_key,
"object":"keys"
})));
}
user.private_key = Some(data.encrypted_private_key); user.private_key = Some(data.encrypted_private_key);
user.public_key = Some(data.public_key); user.public_key = Some(data.public_key);
@ -798,6 +919,20 @@ struct RotateAccountUnlockData {
emergency_access_unlock_data: Vec<UpdateEmergencyAccessData>, emergency_access_unlock_data: Vec<UpdateEmergencyAccessData>,
master_password_unlock_data: MasterPasswordUnlockData, master_password_unlock_data: MasterPasswordUnlockData,
organization_account_recovery_unlock_data: Vec<UpdateResetPasswordData>, organization_account_recovery_unlock_data: Vec<UpdateResetPasswordData>,
/// The user key, re-wrapped for every device that unlocks the vault without a master password.
///
/// Absent rather than empty tells the two generations of clients apart: one that sends this rotates
/// the trust of its devices right here, an older one does it afterwards through
/// `POST /devices/update-trust` and leaves this out entirely. See `post_rotatekey`.
device_key_unlock_data: Option<Vec<UpdateDeviceKeysData>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateDeviceKeysData {
device_id: DeviceId,
encrypted_user_key: String,
encrypted_public_key: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -827,6 +962,52 @@ struct RotateAccountData {
sends: Vec<SendData>, sends: Vec<SendData>,
} }
/// Works out what a key rotation has to write to the user's devices.
///
/// Returns the devices that keep their trust, each with the user key freshly wrapped for it; whatever
/// the user owns beyond that list ends up untrusted, so the caller can hand the result to
/// `Device::replace_trust` and be done in one transaction. Mirrors `DeviceRotationValidator` upstream,
/// which refuses a rotation that would quietly drop the trust of a device the user still relies on.
/// https://github.com/bitwarden/server/blob/main/src/Api/KeyManagement/Validators/DeviceRotationValidator.cs
fn validate_device_keydata(
updates: &[UpdateDeviceKeysData],
existing_devices: &[Device],
) -> ApiResult<Vec<(DeviceId, String, String)>> {
// Everything the client sent is checked before any of it is used, so a request that is
// malformed anywhere is refused as a whole rather than answered in part.
let mut listed: HashMap<&DeviceId, &UpdateDeviceKeysData> = HashMap::with_capacity(updates.len());
for update in updates {
if listed.insert(&update.device_id, update).is_some() {
err!("A device was listed more than once in the rotation")
}
if !existing_devices.iter().any(|device| device.uuid == update.device_id) {
err!(format!("Device {} does not belong to this user", update.device_id))
}
validate_enc_strings(&[
("encryptedUserKey", &update.encrypted_user_key),
("encryptedPublicKey", &update.encrypted_public_key),
])?;
}
// Walked over the devices that are trusted right now rather than over what was sent, because a
// rotation may only carry an existing trust over to the new user key. Trusting a device is a step of
// its own, `PUT /devices/<id>/keys`. An entry for anything else is passed over, as upstream does.
let mut rotated = Vec::new();
for device in existing_devices.iter().filter(|device| device.is_trusted()) {
let Some(update) = listed.get(&device.uuid) else {
err!("All existing trusted devices must be included in the rotation")
};
rotated.push((device.uuid.clone(), update.encrypted_user_key.clone(), update.encrypted_public_key.clone()));
}
Ok(rotated)
}
fn validate_keydata( fn validate_keydata(
data: &KeyData, data: &KeyData,
existing_ciphers: &[Cipher], existing_ciphers: &[Cipher],
@ -931,6 +1112,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// We only rotate the reset password key if it is set. // We only rotate the reset password key if it is set.
existing_memberships.retain(|m| m.reset_password_key.is_some()); existing_memberships.retain(|m| m.reset_password_key.is_some());
let mut existing_sends = Send::find_by_user(user_id, &conn).await; let mut existing_sends = Send::find_by_user(user_id, &conn).await;
let existing_devices = Device::find_by_user(user_id, &conn).await;
validate_keydata( validate_keydata(
&data, &data,
@ -942,6 +1124,11 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
&headers.user, &headers.user,
)?; )?;
let rotated_devices = match data.account_unlock_data.device_key_unlock_data.as_deref() {
Some(updates) => Some(validate_device_keydata(updates, &existing_devices)?),
None => None,
};
// Update folder data // Update folder data
for folder_data in data.account_data.folders { for folder_data in data.account_data.folders {
// Skip `null` folder id entries. // Skip `null` folder id entries.
@ -1004,6 +1191,19 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
} }
} }
// Every device holds the previous user key wrapped for itself, which unlocks nothing anymore. Settle
// that here rather than after the account itself: the ciphers have already been rewritten under the
// new user key, so a device holding the new one is the half that still works if what follows fails.
match rotated_devices {
// The current clients send the re-wrapped user key for every trusted device along with the
// rotation, so their trust survives it. Anything they left out is untrusted here.
Some(rotated) => Device::replace_trust(&headers.user.uuid, rotated, &conn).await?,
// A client old enough to leave the field out does this afterwards through
// `POST /devices/update-trust`. Until it does, no device counts as trusted, so the worst it
// costs its owner is another login rather than an unlock that fails.
None => Device::invalidate_wrapped_user_keys(&headers.user.uuid, &conn).await?,
}
// Update user data // Update user data
let mut user = headers.user; let mut user = headers.user;
@ -1589,6 +1789,247 @@ async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn
put_clear_device_token(device_id, ip, conn).await put_clear_device_token(device_id, ip, conn).await
} }
// Trusted device encryption, see https://bitwarden.com/help/login-with-sso-trusted-devices/
// The three key blobs below are generated and encrypted by the client, the server only stores them and
// hands them back on the next login of that same device. It never learns the device key that unwraps
// `encrypted_private_key`, so a stored trust is worth nothing without the device itself.
// https://github.com/bitwarden/server/blob/main/src/Api/Controllers/DevicesController.cs
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TrustedDeviceKeysData {
encrypted_user_key: String,
encrypted_public_key: String,
encrypted_private_key: String,
}
/// Refuses anything that does not even have the shape of an `EncString`.
///
/// The server cannot tell whether a blob decrypts, but storing something that certainly does not only
/// leaves a device that calls itself trusted and fails its owner at the next unlock. Upstream puts
/// `[EncryptedString]` on the same fields.
fn validate_enc_strings(values: &[(&str, &str)]) -> EmptyResult {
for (name, value) in values {
if !crate::util::is_valid_enc_string(value) {
err!(format!("{name} is not a valid encrypted string"))
}
}
Ok(())
}
/// Marks a device of the current user as trusted.
///
/// Upstream keys this on the device identifier and does not require it to be the device the request was
/// authenticated with, so neither do we. The keys only ever unlock the vault on the device holding the
/// matching device key, so writing them for another of your own devices gains nothing.
#[put("/devices/<device_id>/keys", data = "<data>")]
async fn put_device_keys(
device_id: DeviceId,
data: Json<TrustedDeviceKeysData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
let data = data.into_inner();
validate_enc_strings(&[
("encryptedUserKey", &data.encrypted_user_key),
("encryptedPublicKey", &data.encrypted_public_key),
("encryptedPrivateKey", &data.encrypted_private_key),
])?;
let Some(mut device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await else {
err!("No device found")
};
device.encrypted_user_key = Some(data.encrypted_user_key);
device.encrypted_public_key = Some(data.encrypted_public_key);
device.encrypted_private_key = Some(data.encrypted_private_key);
device.save(true, &conn).await?;
Ok(Json(device.to_json()))
}
// Deprecated upstream in favour of the PUT variant, but still served for older clients
#[post("/devices/<device_id>/keys", data = "<data>")]
async fn post_device_keys(
device_id: DeviceId,
data: Json<TrustedDeviceKeysData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
put_device_keys(device_id, data, headers, conn).await
}
/// The public half of a device's trust, needed by the clients to re-wrap the user key for every
/// trusted device during a key rotation.
#[post("/devices/<device_id>/retrieve-keys")]
async fn post_device_retrieve_keys(device_id: DeviceId, headers: Headers, conn: DbConn) -> JsonResult {
let Some(device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await else {
err!("No device found")
};
Ok(Json(device.to_protected_json()))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeviceTrustUpdateData {
encrypted_user_key: String,
encrypted_public_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OtherDeviceTrustUpdateData {
device_id: DeviceId,
#[serde(flatten)]
keys: DeviceTrustUpdateData,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateDevicesTrustData {
#[serde(flatten)]
secret: PasswordOrOtpData,
current_device: DeviceTrustUpdateData,
#[serde(default)]
other_devices: Vec<OtherDeviceTrustUpdateData>,
}
/// What `POST /devices/update-trust` has to write.
///
/// `rewrapped` holds the user key freshly wrapped for each device that is to keep or regain its trust,
/// `untrusted` the trusted devices that were left out and therefore lose theirs. A device in neither
/// list keeps whatever it holds.
struct DeviceTrustUpdate {
rewrapped: Vec<(DeviceId, String, String)>,
untrusted: Vec<DeviceId>,
}
/// Works out what `POST /devices/update-trust` has to write.
///
/// Only a device that can unlock right now loses anything by being left out; one that cannot is left
/// alone rather than wiped. After a key rotation by a client too old to send `deviceKeyUnlockData` that
/// is a device still holding its own key pair, which is the only thing a later call could restore its
/// trust from, so clearing it here would strand it for good. Mirrors `UpdateDevicesTrustAsync` upstream,
/// which skips every device that is not trusted.
/// https://github.com/bitwarden/server/blob/main/src/Core/Services/Implementations/DeviceService.cs
///
/// Everything is checked before any of it is used, so a request that names a device the user does not
/// own is refused as a whole rather than applied in part.
fn validate_device_trust_update(
current_device_id: &DeviceId,
current_device: DeviceTrustUpdateData,
other_devices: Vec<OtherDeviceTrustUpdateData>,
existing_devices: &[Device],
) -> ApiResult<DeviceTrustUpdate> {
validate_enc_strings(&[
("encryptedUserKey", &current_device.encrypted_user_key),
("encryptedPublicKey", &current_device.encrypted_public_key),
])?;
if !existing_devices.iter().any(|device| &device.uuid == current_device_id) {
err!("No device found")
}
// The current device is written whatever it holds now, as upstream does: it is the one the
// caller is speaking from and just proved it can unlock.
let mut rewrapped =
vec![(current_device_id.clone(), current_device.encrypted_user_key, current_device.encrypted_public_key)];
let mut listed: HashSet<DeviceId> = HashSet::from([current_device_id.clone()]);
for other in other_devices {
if !listed.insert(other.device_id.clone()) {
if &other.device_id == current_device_id {
err!("The current device cannot also be part of the optional rotation")
}
err!("A device was listed more than once in the rotation")
}
let Some(device) = existing_devices.iter().find(|device| device.uuid == other.device_id) else {
err!(format!("Device {} does not belong to this user", other.device_id))
};
validate_enc_strings(&[
("encryptedUserKey", &other.keys.encrypted_user_key),
("encryptedPublicKey", &other.keys.encrypted_public_key),
])?;
// The two keys are wrapped for the device's key pair, so without it there is nothing they
// could belong to. Such a device is passed over rather than written.
if device.holds_private_key() {
rewrapped.push((other.device_id, other.keys.encrypted_user_key, other.keys.encrypted_public_key));
}
}
let untrusted = existing_devices
.iter()
.filter(|device| device.is_trusted() && !listed.contains(&device.uuid))
.map(|device| device.uuid.clone())
.collect();
Ok(DeviceTrustUpdate {
rewrapped,
untrusted,
})
}
/// Re-wraps the user key for the trusted devices after it was replaced by a key rotation.
///
/// Every trusted device that is not listed loses its trust: its stored copy of the user key is the old
/// one. The current clients do this as part of the rotation itself and never come here; this is the route
/// the older ones take, and the only one that can rotate a single device's trust. See `post_rotatekey`.
#[post("/devices/update-trust", data = "<data>")]
async fn post_devices_update_trust(data: Json<UpdateDevicesTrustData>, headers: Headers, conn: DbConn) -> EmptyResult {
let data = data.into_inner();
data.secret.validate(&headers.user, true, &conn).await?;
let devices = Device::find_by_user(&headers.user.uuid, &conn).await;
let update = validate_device_trust_update(&headers.device.uuid, data.current_device, data.other_devices, &devices)?;
Device::update_trust(&headers.user.uuid, update.rewrapped, update.untrusted, &conn).await
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UntrustDevicesData {
devices: Vec<DeviceId>,
}
#[post("/devices/untrust", data = "<data>")]
async fn post_devices_untrust(data: Json<UntrustDevicesData>, headers: Headers, conn: DbConn) -> EmptyResult {
let data = data.into_inner();
let owned: HashSet<DeviceId> =
Device::find_by_user(&headers.user.uuid, &conn).await.into_iter().map(|device| device.uuid).collect();
// Check that the user owns all of them first, so a single foreign id does not leave the request
// half applied.
if let Some(unknown) = data.devices.iter().find(|device_id| !owned.contains(*device_id)) {
err!(format!("Device {unknown} does not belong to this user"))
}
Device::untrust_many(&headers.user.uuid, data.devices, &conn).await
}
/// Reported by a client that still holds a device key but did not get any keys back from us.
///
/// Nothing is left to clean up, the device already counts as untrusted here. Upstream only writes a log
/// line as well, since this points at the client and the server having drifted apart.
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
#[post("/devices/lost-trust")]
fn post_devices_lost_trust(headers: Headers) -> EmptyResult {
warn!(
"Device {} ({}) of user {} still holds a device key, but has no trusted device keys on the server",
headers.device.uuid,
DeviceType::from_i32(headers.device.atype),
headers.user.uuid
);
Ok(())
}
#[get("/tasks")] #[get("/tasks")]
fn get_tasks(_client_headers: ClientHeaders) -> JsonResult { fn get_tasks(_client_headers: ClientHeaders) -> JsonResult {
Ok(Json(json!({ Ok(Json(json!({
@ -1604,9 +2045,57 @@ struct AuthRequestRequest {
device_identifier: DeviceId, device_identifier: DeviceId,
email: String, email: String,
public_key: String, public_key: String,
// Not used for now #[serde(default, rename = "type")]
// #[serde(alias = "type")] atype: i32,
// _type: i32, }
/// Upstream puts `[StringLength(25)]` on the access code, so no client sends more than that.
/// https://github.com/bitwarden/server/blob/main/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs
const MAX_ACCESS_CODE_LENGTH: usize = 25;
/// A base64 SPKI RSA-4096 public key is under a kilobyte; this leaves room for whatever comes next.
const MAX_REQUEST_PUBLIC_KEY_LENGTH: usize = 4096;
impl AuthRequestRequest {
/// Both of these end up stored, and the admin approval route stores a copy per organization the user
/// belongs to, so neither may be unbounded. The public key is handed to the answering client as
/// base64 to wrap a key against; one that is not base64 would break the page listing the requests.
fn validate(&self) -> EmptyResult {
if self.access_code.is_empty() || self.access_code.len() > MAX_ACCESS_CODE_LENGTH {
err!("Invalid access code")
}
if self.public_key.is_empty()
|| self.public_key.len() > MAX_REQUEST_PUBLIC_KEY_LENGTH
|| data_encoding::BASE64.decode(self.public_key.as_bytes()).is_err()
{
err!("Invalid public key")
}
Ok(())
}
}
fn auth_request_json(auth_request: &AuthRequest) -> Value {
json!({
"id": auth_request.uuid,
"publicKey": auth_request.public_key,
"type": auth_request.atype,
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
// The clients read the raw enum value as well, to pick an icon for the asking device.
"requestDeviceTypeValue": auth_request.device_type,
"requestDeviceIdentifier": auth_request.request_device_identifier,
"requestIpAddress": auth_request.request_ip,
// Not recorded here, but the clients read it, so it is answered rather than missing.
"requestCountryName": null,
"key": auth_request.enc_key,
"masterPasswordHash": auth_request.master_password_hash,
"creationDate": format_date(&auth_request.creation_date),
"responseDate": auth_request.response_date.as_ref().map(format_date),
"requestApproved": auth_request.approved.unwrap_or(false),
"origin": CONFIG.domain_origin(),
"object": "auth-request"
})
} }
#[post("/auth-requests", data = "<data>")] #[post("/auth-requests", data = "<data>")]
@ -1620,6 +2109,14 @@ async fn post_auth_request(
let data = data.into_inner(); let data = data.into_inner();
// Asking an administrator for approval means telling them who is asking, so that one is only
// available to a caller who has already proven who they are. See `post_admin_auth_request`.
if AuthRequestType::from_i32(data.atype) == Some(AuthRequestType::AdminApproval) {
err!("You must be authenticated to create a request of that type")
}
data.validate()?;
let Some(user) = User::find_by_mail(&data.email, &conn).await else { let Some(user) = User::find_by_mail(&data.email, &conn).await else {
err!("AuthRequest doesn't exist", "User not found") err!("AuthRequest doesn't exist", "User not found")
}; };
@ -1630,8 +2127,14 @@ async fn post_auth_request(
_ => err!("AuthRequest doesn't exist", "Device verification failed"), _ => err!("AuthRequest doesn't exist", "Device verification failed"),
}; };
let Some(atype) = AuthRequestType::from_i32(data.atype) else {
err!("Unknown auth request type")
};
let auth_request = AuthRequest::new( let auth_request = AuthRequest::new(
user.uuid.clone(), user.uuid.clone(),
None,
atype,
data.device_identifier.clone(), data.device_identifier.clone(),
client_headers.device_type, client_headers.device_type,
client_headers.ip.ip.to_string(), client_headers.ip.ip.to_string(),
@ -1651,19 +2154,136 @@ async fn post_auth_request(
) )
.await; .await;
Ok(Json(json!({ Ok(Json(auth_request_json(&auth_request)))
"id": auth_request.uuid, }
"publicKey": auth_request.public_key,
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), /// Asks the administrators of every organization the user belongs to to let this device in.
"requestIpAddress": auth_request.request_ip, ///
"key": null, /// The way out for someone who unlocks with trusted devices and has no other device left to ask. One
"masterPasswordHash": null, /// request per organization, so whichever administrator gets there first can answer.
"creationDate": format_date(&auth_request.creation_date), /// https://github.com/bitwarden/server/blob/main/src/Api/Auth/Controllers/AuthRequestsController.cs
"responseDate": null, #[post("/auth-requests/admin-request", data = "<data>")]
"requestApproved": false, async fn post_admin_auth_request(data: Json<AuthRequestRequest>, headers: Headers, conn: DbConn) -> JsonResult {
"origin": CONFIG.domain_origin(), // Every call mails all administrators of every organization involved, so it is worth its own limit.
"object": "auth-request" crate::ratelimit::check_limit_unauthenticated(&headers.ip.ip)?;
})))
let data = data.into_inner();
if AuthRequestType::from_i32(data.atype) != Some(AuthRequestType::AdminApproval) {
err!("Invalid auth request type, expected admin approval")
}
if data.device_identifier != headers.device.uuid {
err!("AuthRequest doesn't exist", "Device verification failed")
}
data.validate()?;
// Only an organization that could actually answer is asked. Approving hands the member their own
// user key, which an administrator can only do with the key that enrolling into account recovery
// left them, so an organization without one has nothing to offer and needs none of the asker's data.
let memberships: Vec<Membership> = Membership::find_by_user(&headers.user.uuid, &conn)
.await
.into_iter()
.filter(Membership::can_use_admin_approval)
.collect();
if memberships.is_empty() {
err!("User does not belong to any organization that could approve a device")
}
log_user_event(
EventType::UserRequestedDeviceApproval as i32,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
let mut first_request = None;
for membership in memberships {
// Repeating the very same request is answered with the row it already has, so a client that
// sends it twice does not pile up rows and does not mail the administrators again. What
// identifies the request is the key pair the client generated for it: an approval is the user
// key wrapped for that public key. A client asking again with a new key pair is asking something
// else, and reusing the id would let an administrator approve it for a key already thrown away.
// Upstream never reuses a request at all, it creates one per attempt.
// https://github.com/bitwarden/server/blob/main/src/Core/Auth/Services/Implementations/AuthRequestService.cs
let existing = AuthRequest::find_pending_admin_approval(
&headers.user.uuid,
&data.device_identifier,
&membership.org_uuid,
&conn,
)
.await
.filter(|request| request.public_key == data.public_key && request.access_code == data.access_code);
let is_new = existing.is_none();
let auth_request = match existing {
Some(mut auth_request) => {
// Only what says where the request is being made from, never the keys it is made
// with; those are what the id stands for.
auth_request.device_type = headers.device.atype;
auth_request.request_ip = headers.ip.ip.to_string();
auth_request.creation_date = Utc::now().naive_utc();
auth_request
}
None => AuthRequest::new(
headers.user.uuid.clone(),
Some(membership.org_uuid.clone()),
AuthRequestType::AdminApproval,
data.device_identifier.clone(),
headers.device.atype,
headers.ip.ip.to_string(),
data.access_code.clone(),
data.public_key.clone(),
),
};
auth_request.save(&conn).await?;
if is_new {
notify_device_approval_requested(&headers.user, &membership.org_uuid, &conn).await;
}
if first_request.is_none() {
first_request = Some(auth_request);
}
}
// Guaranteed by the emptiness check above
let auth_request = first_request.expect("at least one organization");
Ok(Json(auth_request_json(&auth_request)))
}
/// Mails everyone in the organization who could answer the request. Failing to reach them must not
/// undo the request itself, so problems are logged rather than returned.
async fn notify_device_approval_requested(user: &User, org_id: &OrganizationId, conn: &DbConn) {
if !CONFIG.mail_enabled() {
return;
}
let Some(org) = Organization::find_by_uuid(org_id, conn).await else {
return;
};
// The same set that may answer the request, see `ManageResetPasswordHeaders`. Mailing anyone
// else would tell them who is asking for something they cannot do anything about.
let approvers = Membership::find_confirmed_by_org(org_id, conn)
.await
.into_iter()
.filter(Membership::can_manage_reset_password_now);
for approver in approvers {
let Some(admin) = User::find_by_uuid(&approver.user_uuid, conn).await else {
continue;
};
if let Err(e) =
mail::send_device_approval_requested(&admin.email, org_id, &org.name, &user.email, &user.name).await
{
error!("Error sending device approval request email: {e:#?}");
}
}
} }
#[get("/auth-requests/<auth_request_id>")] #[get("/auth-requests/<auth_request_id>")]
@ -1673,21 +2293,13 @@ async fn get_auth_request(auth_request_id: AuthRequestId, headers: Headers, conn
err!("AuthRequest doesn't exist", "Record not found or user uuid does not match") err!("AuthRequest doesn't exist", "Record not found or user uuid does not match")
}; };
let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date)); // The anonymous lookup refuses an expired request, and so does this one: the window an approval
// stays usable in should not depend on which of the two the client happens to poll.
if auth_request.is_expired() {
err!("AuthRequest doesn't exist", "Request has expired")
}
Ok(Json(json!({ Ok(Json(auth_request_json(&auth_request)))
"id": &auth_request_id,
"publicKey": auth_request.public_key,
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
"requestIpAddress": auth_request.request_ip,
"key": auth_request.enc_key,
"masterPasswordHash": auth_request.master_password_hash,
"creationDate": format_date(&auth_request.creation_date),
"responseDate": response_date_utc,
"requestApproved": auth_request.approved,
"origin": CONFIG.domain_origin(),
"object":"auth-request"
})))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -1714,6 +2326,13 @@ async fn put_auth_request(
err!("AuthRequest doesn't exist", "Record not found or user uuid does not match") err!("AuthRequest doesn't exist", "Record not found or user uuid does not match")
}; };
// A request addressed to an administrator is answered through the organization, where the
// permission to do so can actually be checked. Letting the asking user answer it here would
// make the whole detour pointless.
if auth_request.is_admin_approval() {
err!("AuthRequest doesn't exist", "Admin approval requests are answered by the organization")
}
if headers.device.uuid != data.device_identifier { if headers.device.uuid != data.device_identifier {
err!("AuthRequest doesn't exist", "Device verification failed") err!("AuthRequest doesn't exist", "Device verification failed")
} }
@ -1722,8 +2341,26 @@ async fn put_auth_request(
err!("An authentication request with the same device already exists") err!("An authentication request with the same device already exists")
} }
if auth_request.is_expired() {
err!("AuthRequest doesn't exist", "Request has expired")
}
// Only the newest request of a device may be approved. Anyone can create a request for a known
// device, so without this an older one could still be sitting there when the user approves what
// their screen shows, and the answer would go to whoever left it. Same check as upstream.
if data.request_approved
&& AuthRequest::find_by_user_and_requested_device(
&headers.user.uuid,
&auth_request.request_device_identifier,
&conn,
)
.await
.is_none_or(|newest| newest.uuid != auth_request.uuid)
{
err!("This request is no longer valid. Make sure to approve the most recent request.")
}
let response_date = Utc::now().naive_utc(); let response_date = Utc::now().naive_utc();
let response_date_utc = format_date(&response_date);
if data.request_approved { if data.request_approved {
auth_request.approved = Some(data.request_approved); auth_request.approved = Some(data.request_approved);
@ -1734,7 +2371,7 @@ async fn put_auth_request(
auth_request.save(&conn).await?; auth_request.save(&conn).await?;
ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await; ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await;
nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await; nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, Some(&headers.device), &conn).await;
log_user_event( log_user_event(
EventType::OrganizationUserApprovedAuthRequest as i32, EventType::OrganizationUserApprovedAuthRequest as i32,
@ -1757,19 +2394,7 @@ async fn put_auth_request(
.await; .await;
} }
Ok(Json(json!({ Ok(Json(auth_request_json(&auth_request)))
"id": &auth_request_id,
"publicKey": auth_request.public_key,
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
"requestIpAddress": auth_request.request_ip,
"key": auth_request.enc_key,
"masterPasswordHash": auth_request.master_password_hash,
"creationDate": format_date(&auth_request.creation_date),
"responseDate": response_date_utc,
"requestApproved": auth_request.approved,
"origin": CONFIG.domain_origin(),
"object":"auth-request"
})))
} }
#[get("/auth-requests/<auth_request_id>/response?<code>")] #[get("/auth-requests/<auth_request_id>/response?<code>")]
@ -1792,21 +2417,11 @@ async fn get_auth_request_response(
err!("AuthRequest doesn't exist", "Invalid device, IP or code") err!("AuthRequest doesn't exist", "Invalid device, IP or code")
} }
let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date)); if auth_request.is_expired() {
err!("AuthRequest doesn't exist", "Request has expired")
}
Ok(Json(json!({ Ok(Json(auth_request_json(&auth_request)))
"id": &auth_request_id,
"publicKey": auth_request.public_key,
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
"requestIpAddress": auth_request.request_ip,
"key": auth_request.enc_key,
"masterPasswordHash": auth_request.master_password_hash,
"creationDate": format_date(&auth_request.creation_date),
"responseDate": response_date_utc,
"requestApproved": auth_request.approved,
"origin": CONFIG.domain_origin(),
"object":"auth-request"
})))
} }
// Now unused but not yet removed // Now unused but not yet removed
@ -1823,7 +2438,8 @@ async fn get_auth_requests_pending(headers: Headers, conn: DbConn) -> JsonResult
Ok(Json(json!({ Ok(Json(json!({
"data": auth_requests "data": auth_requests
.iter() .iter()
.filter(|request| request.approved.is_none()) // The same set a device answers for itself, see `find_by_user_and_requested_device`.
.filter(|request| request.approved.is_none() && !request.is_admin_approval() && !request.is_expired())
.map(|request| { .map(|request| {
let response_date_utc = request.response_date.map(|response_date| format_date(&response_date)); let response_date_utc = request.response_date.map(|response_date| format_date(&response_date));
@ -1854,3 +2470,81 @@ pub async fn purge_auth_requests(pool: DbPool) {
error!("Failed to get DB connection while purging auth requests"); error!("Failed to get DB connection while purging auth requests");
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn device(id: &str, trusted: bool) -> Device {
let mut device = Device::new(id.to_owned().into(), String::from("user").into(), String::new(), 9);
if trusted {
device.encrypted_user_key = Some(String::from("4.b2xkdXNlcmtleQ=="));
device.encrypted_public_key = Some(String::from("2.aXY=|Y2lwaGVy|bWFj"));
device.encrypted_private_key = Some(String::from("2.aXY=|Y2lwaGVy|bWFj"));
}
device
}
/// A device left holding nothing but its own key pair, which is what a rotation by a client too old
/// to send `deviceKeyUnlockData` leaves behind. It does not unlock anything as it stands.
fn half_trusted(id: &str) -> Device {
let mut device = device(id, true);
device.encrypted_user_key = None;
device.encrypted_public_key = None;
device
}
fn update(device_id: &str) -> UpdateDeviceKeysData {
UpdateDeviceKeysData {
device_id: device_id.to_owned().into(),
encrypted_user_key: String::from("4.bmV3dXNlcmtleQ=="),
encrypted_public_key: String::from("2.aXY=|bmV3|bWFj"),
}
}
/// The ids and keys the rotation would write, so a test can say what it expects in one line.
fn rotated(result: &[(DeviceId, String, String)]) -> Vec<String> {
result.iter().map(|(device_id, user_key, _)| format!("{device_id}={user_key}")).collect()
}
/// A rotation only carries an existing trust over to the new user key. Trusting a device is
/// `PUT /devices/<id>/keys`, taken by the device itself once it holds the device key these blobs are
/// wrapped for, so listing any other device gains it nothing and the write that follows clears it.
#[test]
fn only_the_devices_trusted_before_are_rotated() {
let devices = [device("a", true), device("b", true), device("c", false), half_trusted("d")];
let result = validate_device_keydata(&[update("a"), update("b"), update("c"), update("d")], &devices).unwrap();
assert_eq!(rotated(&result), ["a=4.bmV3dXNlcmtleQ==", "b=4.bmV3dXNlcmtleQ=="], "both re-wrapped");
// Neither of the others unlocks anything as it stands, so leaving them out loses no trust.
let result = validate_device_keydata(&[update("a"), update("b")], &devices).unwrap();
assert_eq!(rotated(&result), ["a=4.bmV3dXNlcmtleQ==", "b=4.bmV3dXNlcmtleQ=="]);
let devices = [device("c", false), half_trusted("d")];
assert!(validate_device_keydata(&[], &devices).unwrap().is_empty());
assert!(validate_device_keydata(&[update("c"), update("d")], &devices).unwrap().is_empty());
}
#[test]
fn a_malformed_rotation_is_refused_as_a_whole() {
let devices = [device("a", true), device("b", true)];
let mut bad_user_key = update("a");
bad_user_key.encrypted_user_key = String::from("not an enc string");
let mut bad_public_key = update("a");
bad_public_key.encrypted_public_key = String::new();
for (updates, expected) in [
// Silently dropping the trust of a device the user still relies on is not the server's call.
(vec![update("a")], "All existing trusted devices must be included"),
(vec![update("a"), update("b"), update("stranger")], "does not belong to this user"),
// Two entries for one device means one of the two keys is dropped without anyone noticing which.
(vec![update("a"), update("a"), update("b")], "listed more than once"),
(vec![bad_user_key, update("b")], "encryptedUserKey"),
(vec![bad_public_key, update("b")], "encryptedPublicKey"),
] {
let err = validate_device_keydata(&updates, &devices).unwrap_err();
assert!(format!("{err}").contains(expected), "expected {expected:?}, got {err}");
}
}
}

330
src/api/core/organizations.rs

@ -1,5 +1,6 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use chrono::Utc;
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use rocket::{Route, http::Status, serde::json::Json}; use rocket::{Route, http::Status, serde::json::Json};
use serde_json::Value; use serde_json::Value;
@ -8,17 +9,20 @@ use crate::{
CONFIG, CONFIG,
api::admin::FAKE_ADMIN_UUID, api::admin::FAKE_ADMIN_UUID,
api::{ api::{
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, AnonymousNotify, 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, ManageResetPasswordHeaders, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders,
OwnerHeaders, decode_invite,
},
db::{ db::{
DbConn, DbConn,
models::{ models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, AuthRequest, AuthRequestId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, CollectionUser, DeviceType, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey,
UserId, OrganizationId, TwoFactor, TwoFactorType, User, UserId,
}, },
}, },
mail, mail,
@ -98,6 +102,10 @@ pub fn routes() -> Vec<Route> {
get_reset_password_details, get_reset_password_details,
put_reset_password, put_reset_password,
put_recover_account, put_recover_account,
get_organization_auth_requests,
deny_organization_auth_requests,
update_organization_auth_request,
update_many_organization_auth_requests,
get_org_export, get_org_export,
post_api_key, post_api_key,
rotate_api_key, rotate_api_key,
@ -3193,7 +3201,13 @@ async fn put_reset_password_enrollment(
err!("Reset password can't be withdrawn due to an enterprise policy"); err!("Reset password can't be withdrawn due to an enterprise policy");
} }
if reset_password_key.is_some() { // An account that unlocks with a trusted device has no master password to verify against, and the
// clients send nothing but the key when they enroll as part of that flow. Upstream carves out the
// same exception, keyed on the organization's SSO configuration rather than a server-wide setting.
// https://github.com/bitwarden/server/blob/main/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
let trusted_device_enrollment = CONFIG.sso_trusted_device_encryption() && headers.user.password_hash.is_empty();
if reset_password_key.is_some() && !trusted_device_enrollment {
PasswordOrOtpData { PasswordOrOtpData {
master_password_hash: reset_request.master_password_hash, master_password_hash: reset_request.master_password_hash,
otp: reset_request.otp, otp: reset_request.otp,
@ -3202,21 +3216,317 @@ async fn put_reset_password_enrollment(
.await?; .await?;
} }
let enrolled = reset_password_key.is_some();
let membership_id = membership.uuid.clone();
// Enrolling is where a member who was invited into a trusted device organization turns into a real
// one; upstream accepts the invitation at this point as well, without it they would stay invited
// forever. Tied to the same condition as the exception above, so turning the feature off leaves the
// invitation flow as it was: an invitation is otherwise accepted only against the mailed token, the
// only thing proving the address belongs to the account.
if enrolled && trusted_device_enrollment && membership.status == MembershipStatus::Invited as i32 {
// Do not leave the open invitation behind, it would keep the address signup-eligible.
Invitation::take(&headers.user.email, &conn).await;
accept_org_invite(&headers.user, membership, reset_password_key, &conn).await?;
} else {
membership.reset_password_key = reset_password_key; membership.reset_password_key = reset_password_key;
membership.save(&conn).await?; membership.save(&conn).await?;
}
let event_type = if membership.reset_password_key.is_some() { // Asked of the key that was just written rather than of the membership, which the branch above
// may have handed to `accept_org_invite`.
let event_type = if enrolled {
EventType::OrganizationUserResetPasswordEnroll EventType::OrganizationUserResetPasswordEnroll
} else { } else {
EventType::OrganizationUserResetPasswordWithdraw EventType::OrganizationUserResetPasswordWithdraw
}; };
log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn) log_event(event_type, &membership_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await; .await;
Ok(()) Ok(())
} }
// Device approvals. A member who unlocks with a trusted device and has no other device of their own left
// to ask can turn to the administrators of their organization instead. Answering means handing them their
// own user key, encrypted for the key pair of the asking device, which is only possible because the
// member enrolled into account recovery beforehand.
// https://github.com/bitwarden/server/blob/main/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs
/// The requests waiting for an answer in this organization.
#[get("/organizations/<org_id>/auth-requests")]
async fn get_organization_auth_requests(
org_id: OrganizationId,
headers: ManageResetPasswordHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
let mut requests = Vec::new();
for auth_request in AuthRequest::find_pending_admin_approval_by_org(&org_id, &conn).await {
if auth_request.is_expired() {
continue;
}
// A request this organization could not answer anyway is none of its business, so it is
// quietly left out instead of being offered for approval. Same condition as when answering,
// so nothing is shown here that would be refused there.
let (Some(member), Some(user)) = (
Membership::find_by_user_and_org(&auth_request.user_uuid, &org_id, &conn).await,
User::find_by_uuid(&auth_request.user_uuid, &conn).await,
) else {
continue;
};
if !member.can_use_admin_approval() {
continue;
}
requests.push(auth_request.to_json_for_organization(&user.email, &member.uuid));
}
Ok(Json(json!({
"data": requests,
"continuationToken": null,
"object": "list"
})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AdminAuthRequestUpdateData {
request_approved: bool,
encrypted_user_key: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BulkDenyAuthRequestData {
ids: Vec<AuthRequestId>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationAuthRequestUpdateData {
id: AuthRequestId,
approved: bool,
key: Option<String>,
}
/// How many requests one call may answer. A screen full of pending approvals is a handful.
const MAX_BULK_AUTH_REQUESTS: usize = 500;
/// Whether one entry that cannot be answered takes the whole call down with it.
///
/// A single request is addressed by its id, so a caller that names an unanswerable one deserves to hear
/// about it. A batch is a list of what an administrator saw a moment ago, where an entry may well have
/// expired or been answered by a colleague since; upstream processes those as far as it can and passes
/// over the rest, rather than reporting an error after already answering everything before it.
#[derive(Clone, Copy, PartialEq, Eq)]
enum OnUnanswerable {
Fail,
Skip,
}
#[post("/organizations/<org_id>/auth-requests/<request_id>", data = "<data>", rank = 2)]
async fn update_organization_auth_request(
org_id: OrganizationId,
request_id: AuthRequestId,
data: Json<AdminAuthRequestUpdateData>,
headers: ManageResetPasswordHeaders,
conn: DbConn,
ant: AnonymousNotify<'_>,
nt: Notify<'_>,
) -> EmptyResult {
let data = data.into_inner();
answer_organization_auth_request(
&org_id,
&request_id,
data.request_approved,
data.encrypted_user_key,
OnUnanswerable::Fail,
&headers,
&conn,
&ant,
&nt,
)
.await
}
#[post("/organizations/<org_id>/auth-requests/deny", data = "<data>", rank = 1)]
async fn deny_organization_auth_requests(
org_id: OrganizationId,
data: Json<BulkDenyAuthRequestData>,
headers: ManageResetPasswordHeaders,
conn: DbConn,
ant: AnonymousNotify<'_>,
nt: Notify<'_>,
) -> EmptyResult {
let ids = data.into_inner().ids;
if ids.len() > MAX_BULK_AUTH_REQUESTS {
err!(format!("At most {MAX_BULK_AUTH_REQUESTS} requests can be answered at once"))
}
for request_id in ids {
answer_organization_auth_request(
&org_id,
&request_id,
false,
None,
OnUnanswerable::Skip,
&headers,
&conn,
&ant,
&nt,
)
.await?;
}
Ok(())
}
#[post("/organizations/<org_id>/auth-requests", data = "<data>")]
async fn update_many_organization_auth_requests(
org_id: OrganizationId,
data: Json<Vec<OrganizationAuthRequestUpdateData>>,
headers: ManageResetPasswordHeaders,
conn: DbConn,
ant: AnonymousNotify<'_>,
nt: Notify<'_>,
) -> EmptyResult {
let updates = data.into_inner();
if updates.len() > MAX_BULK_AUTH_REQUESTS {
err!(format!("At most {MAX_BULK_AUTH_REQUESTS} requests can be answered at once"))
}
for update in updates {
answer_organization_auth_request(
&org_id,
&update.id,
update.approved,
update.key,
OnUnanswerable::Skip,
&headers,
&conn,
&ant,
&nt,
)
.await?;
}
Ok(())
}
#[expect(clippy::too_many_arguments, reason = "Rocket request guards have to be passed through")]
async fn answer_organization_auth_request(
org_id: &OrganizationId,
request_id: &AuthRequestId,
approved: bool,
encrypted_user_key: Option<String>,
on_unanswerable: OnUnanswerable,
headers: &ManageResetPasswordHeaders,
conn: &DbConn,
ant: &AnonymousNotify<'_>,
nt: &Notify<'_>,
) -> EmptyResult {
if org_id != &headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
// Everything below this point is a request that this administrator cannot answer, whether it
// never existed, was already dealt with, or ran out. In a batch that is expected and skipped.
macro_rules! unanswerable {
($($err:tt)*) => {{
if on_unanswerable == OnUnanswerable::Skip {
return Ok(());
}
err!($($err)*)
}};
}
// Only ever reachable through the organization it was addressed to, so an administrator cannot
// answer for an organization they have no say in.
let Some(mut auth_request) = AuthRequest::find_admin_approval_by_org_and_uuid(request_id, org_id, conn).await
else {
unanswerable!("AuthRequest doesn't exist", "Record not found or not addressed to this organization")
};
if auth_request.approved.is_some() {
unanswerable!("This request has already been answered");
}
if auth_request.is_expired() {
unanswerable!("AuthRequest doesn't exist", "Request has expired");
}
// Answering means acting for a member of this organization with the key their enrollment into
// account recovery left behind: an invitation that was never accepted is not a membership yet,
// a revoked one is not one anymore, and without that key there is nothing to answer with.
let member = match Membership::find_by_user_and_org(&auth_request.user_uuid, org_id, conn).await {
Some(member) if member.can_use_admin_approval() => member,
_ => unanswerable!("AuthRequest doesn't exist", "The requesting user is not a member of this organization"),
};
if approved {
// Without the wrapped user key the answer is worthless: it is the whole point of approving.
let Some(key) = encrypted_user_key.filter(|key| !key.is_empty()) else {
unanswerable!("An approved request needs the encrypted user key")
};
if !crate::util::is_valid_enc_string(&key) {
unanswerable!("encryptedUserKey is not a valid encrypted string");
}
auth_request.enc_key = Some(key);
}
auth_request.approved = Some(approved);
auth_request.response_date = Some(Utc::now().naive_utc());
auth_request.save(conn).await?;
let event_type = if approved {
EventType::OrganizationUserApprovedAuthRequest
} else {
EventType::OrganizationUserRejectedAuthRequest
};
log_event(event_type, &member.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn).await;
// A denial is deliberately not announced. If the request came from somebody who is not the
// member, telling them that it was seen and refused is more than they should learn.
if !approved {
return Ok(());
}
ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await;
// No acting device: the answer did not come from one of this user's devices, so every one of them,
// the one that asked above all, should hear about it. Naming the administrator's device here would
// hand its identifiers to the push relay under a foreign user id. See `push_auth_response`.
nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, None, conn).await;
if CONFIG.mail_enabled()
&& let Some(user) = User::find_by_uuid(&auth_request.user_uuid, conn).await
&& let Some(org) = Organization::find_by_uuid(org_id, conn).await
{
let device =
format!("{} - {}", DeviceType::from_i32(auth_request.device_type), auth_request.request_device_identifier);
let approved_at = auth_request.response_date.unwrap_or_else(|| Utc::now().naive_utc());
if let Err(e) = mail::send_trusted_device_admin_approval(
&user.email,
&org.name,
&approved_at,
&auth_request.request_ip,
&device,
)
.await
{
error!("Error sending trusted device approval email: {e:#?}");
}
}
Ok(())
}
// NOTE: It seems clients can't handle uppercase-first keys!! // NOTE: It seems clients can't handle uppercase-first keys!!
// We need to convert all keys so they have the first character to be a lowercase. // We need to convert all keys so they have the first character to be a lowercase.
// Else the export will be just an empty JSON file. // Else the export will be just an empty JSON file.
@ -3254,7 +3564,7 @@ async fn api_key(
let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await { let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await {
if rotate { if rotate {
org_api_key.api_key = crate::crypto::generate_api_key(); org_api_key.api_key = crate::crypto::generate_api_key();
org_api_key.revision_date = chrono::Utc::now().naive_utc(); org_api_key.revision_date = Utc::now().naive_utc();
org_api_key.save(&conn).await.expect("Error rotating organization API Key"); org_api_key.save(&conn).await.expect("Error rotating organization API Key");
} }
org_api_key org_api_key

3
src/api/core/sends.rs

@ -35,6 +35,9 @@ static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
push_token: None, push_token: None,
refresh_token: String::new(), refresh_token: String::new(),
twofactor_remember: None, twofactor_remember: None,
encrypted_user_key: None,
encrypted_public_key: None,
encrypted_private_key: None,
} }
}); });

226
src/api/identity.rs

@ -30,9 +30,9 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::{ models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, Membership,
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, OIDCCodeResponseError, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, SendId,
TwoFactorType, User, UserId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
}, },
}, },
error::MapResult, error::MapResult,
@ -382,7 +382,7 @@ async fn sso_login(
// We passed 2FA get auth tokens // We passed 2FA get auth tokens
let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?; let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?;
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, true, conn, ip).await
} }
async fn password_login( async fn password_login(
@ -504,7 +504,166 @@ async fn password_login(
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, false, conn, ip).await
}
/// Whether the account creation the clients run when nothing else is on offer can succeed here.
///
/// A client that gets the trusted device options, but neither a master password nor an approval an
/// administrator could give, decides it is looking at a fresh account and walks it through creation:
/// generate the account keys and post them, enrol into the account recovery of the organization behind
/// the SSO login, then trust the device. Every one of those has to be able to go through: if enrolment
/// is refused the keys are already written, and the next login fails at posting them a second time,
/// leaving an account that can never be unlocked at all.
///
/// So the same conditions the enrolment endpoint enforces are checked here, before the client has
/// written anything. Withholding the options instead sends it to setting a master password, which works
/// and leaves the door to trusted devices open for the next login.
async fn account_creation_can_succeed(user: &User, conn: &DbConn) -> bool {
// `POST /accounts/keys` refuses to replace the keys of an account that has them, and the
// clients post a freshly generated pair without looking.
if user.private_key.is_some() || user.public_key.is_some() {
return false;
}
// The organization the client enrols into is the one `GET /organizations/<identifier>/auto-enroll-status`
// hands it, so ask the same question here.
let Some(membership) = Membership::find_main_user_org(&user.uuid, conn).await else {
return false;
};
// That lookup only rules out the `Revoked` status itself, which revoking never actually writes: it
// shifts the status out of the active range instead, so a revoked membership comes back from it like
// any other. The enrolment endpoint runs behind `OrgMemberHeaders` and turns exactly those away.
if !membership.is_active() {
return false;
}
// What `check_reset_password_applicable` demands of that organization.
if !CONFIG.mail_enabled() {
return false;
}
if !OrgPolicy::find_by_org_and_type(&membership.org_uuid, OrgPolicyType::ResetPassword, conn)
.await
.is_some_and(|policy| policy.enabled)
{
return false;
}
// Enrolling wraps the user key for the organization, so it needs its public key.
Organization::find_by_uuid(&membership.org_uuid, conn)
.await
.is_some_and(|org| org.public_key.is_some_and(|key| !key.is_empty()))
}
/// The ways an account could get through the trusted device flow, which is what decides whether
/// offering it leads anywhere.
#[expect(
clippy::struct_excessive_bools,
reason = "Four independent facts about one account, not a state that could be an enum"
)]
struct TrustedDeviceWaysIn {
/// This device already holds the keys, so it unlocks without asking anyone.
device_is_trusted: bool,
/// A master password to fall back on.
has_master_password: bool,
/// An administrator of an organization who could let a new device in, which they can only do
/// once the member enrolled into account recovery.
has_admin_approval: bool,
/// Nothing set up yet, but the account creation the clients run in that case would go through.
can_create_account: bool,
}
impl TrustedDeviceWaysIn {
/// Whether the trusted device options belong in a login response, and in which of their two roles.
///
/// `Some(true)` means they are only there to walk a user without a master password off the feature
/// after it was switched off; `None` means they are withheld, because nothing the client could do
/// with them would work. The order mirrors how the clients read them: a trusted device unlocks
/// straight away, otherwise an administrator to ask or a master password to type is offered, and only
/// when there is neither does the client try to create a fresh account.
fn offer(&self, enabled: bool) -> Option<bool> {
// Once the feature is switched off again, a user without a master password would be locked out
// of their own vault. Keep telling their still trusted devices about it so their client can walk
// them through setting one while they can still unlock.
let offboarding = !enabled && self.offboarding_candidate();
if !(enabled || offboarding) {
return None;
}
let leads_somewhere =
self.device_is_trusted || self.has_admin_approval || self.has_master_password || self.can_create_account;
leads_somewhere.then_some(offboarding)
}
/// A user who is still on a trusted device and has no master password to fall back on, and so
/// has to be told when the feature goes away.
fn offboarding_candidate(&self) -> bool {
self.device_is_trusted && !self.has_master_password
}
}
/// Trusted device encryption ("passwordless SSO"): instead of deriving the user key from a master
/// password, the client keeps a copy of it on the device, wrapped for a key pair that the device
/// generated. Its presence in the response is what makes the clients offer the flow at all.
///
/// Upstream ties this to the SSO configuration of an organization; Vaultwarden configures SSO for the
/// whole server, so `SSO_TRUSTED_DEVICE_ENCRYPTION` decides it here. Either way it stays an SSO feature,
/// a password login never gets these options.
/// https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs
async fn trusted_device_option(user: &User, device: &Device, conn: &DbConn) -> Option<Value> {
let enabled = CONFIG.sso_trusted_device_encryption();
let mut ways_in = TrustedDeviceWaysIn {
device_is_trusted: device.is_trusted(),
has_master_password: !user.password_hash.is_empty(),
has_admin_approval: false,
can_create_account: false,
};
// Answered ahead of everything else so a server that does not offer trusted devices, and has no
// user left on them, does no work for the feature at all.
if !(enabled || ways_in.offboarding_candidate()) {
return None;
}
let memberships = Membership::find_by_user(&user.uuid, conn).await;
// An admin can only take over the approval once the member handed them a key to work with, which is
// what enrolling into account recovery does. The same condition the request itself is created and
// answered under, so this does not announce a way out that would be refused the moment it is taken.
ways_in.has_admin_approval = memberships.iter().any(Membership::can_use_admin_approval);
// Only worth asking when nothing cheaper already lets the client in.
if !(ways_in.device_is_trusted || ways_in.has_admin_approval || ways_in.has_master_password) {
ways_in.can_create_account = account_creation_can_succeed(user, conn).await;
}
let offboarding = ways_in.offer(enabled)?;
// Any other device of this user that could show an approval prompt. The user unlocks a new
// device from one of these, or with the master password if they have one.
let has_login_approving_device = Device::find_by_user(&user.uuid, conn)
.await
.iter()
.any(|other| other.uuid != device.uuid && DeviceType::from_i32(other.atype).can_approve_login_requests());
// Whether the user is on the answering side of that. The clients use it to push someone who could
// approve others, but has no master password themselves, into setting one. Every active membership
// counts, not only a confirmed one: an administrator provisioned by this very login holds the role
// before anybody has confirmed them. See `has_manage_reset_password_role_for_tde`.
let has_manage_reset_password_permission =
memberships.iter().any(Membership::has_manage_reset_password_role_for_tde);
Some(json!({
"HasAdminApproval": ways_in.has_admin_approval,
"HasLoginApprovingDevice": has_login_approving_device,
"HasManageResetPasswordPermission": has_manage_reset_password_permission,
"IsTdeOffboarding": offboarding,
"EncryptedPrivateKey": device.trusted_private_key(),
"EncryptedUserKey": device.trusted_user_key(),
"Object": "trustedDeviceUserDecryptionOption"
}))
} }
async fn authenticated_response( async fn authenticated_response(
@ -512,6 +671,7 @@ async fn authenticated_response(
device: &mut Device, device: &mut Device,
auth_tokens: auth::AuthTokens, auth_tokens: auth::AuthTokens,
twofactor_token: Option<String>, twofactor_token: Option<String>,
sso_login: bool,
conn: &DbConn, conn: &DbConn,
ip: &ClientIp, ip: &ClientIp,
) -> JsonResult { ) -> JsonResult {
@ -573,6 +733,16 @@ async fn authenticated_response(
Value::Null Value::Null
}; };
let mut user_decryption_options = json!({
"HasMasterPassword": has_master_password,
"MasterPasswordUnlock": master_password_unlock,
"Object": "userDecryptionOptions"
});
if sso_login && let Some(option) = trusted_device_option(user, device, conn).await {
user_decryption_options["TrustedDeviceOption"] = option;
}
let mut result = json!({ let mut result = json!({
"access_token": auth_tokens.access_token(), "access_token": auth_tokens.access_token(),
"expires_in": auth_tokens.expires_in(), "expires_in": auth_tokens.expires_in(),
@ -588,11 +758,7 @@ async fn authenticated_response(
"MasterPasswordPolicy": master_password_policy, "MasterPasswordPolicy": master_password_policy,
"scope": auth_tokens.scope(), "scope": auth_tokens.scope(),
"AccountKeys": account_keys, "AccountKeys": account_keys,
"UserDecryptionOptions": { "UserDecryptionOptions": user_decryption_options,
"HasMasterPassword": has_master_password,
"MasterPasswordUnlock": master_password_unlock,
"Object": "userDecryptionOptions"
},
}); });
if !user.akey.is_empty() { if !user.akey.is_empty() {
@ -1367,3 +1533,43 @@ 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::*;
/// Withheld wherever nothing the client could do with them would work. Once the feature is switched
/// off they are only kept for a user left on a trusted device without a master password, so their
/// client can walk them off the feature while they can still unlock.
#[test]
fn the_trusted_device_options_are_offered_where_they_lead_somewhere() {
// enabled, device trusted, master password, admin approval, can create an account => offer
for (enabled, device_is_trusted, has_master_password, has_admin_approval, can_create_account, expected) in [
(false, false, false, false, false, None),
(false, false, true, false, false, None),
(false, true, true, false, false, None),
(false, true, false, false, false, Some(true)),
// Only a device that still unlocks is walked off, an administrator to ask is no reason to.
(false, false, false, true, false, None),
// Nothing set up, nobody to ask, and account creation would fail at the enrolment: the one
// combination that would leave the account half built.
(true, false, false, false, false, None),
(true, true, false, false, false, Some(false)),
(true, false, true, false, false, Some(false)),
(true, false, false, true, false, Some(false)),
(true, false, false, false, true, Some(false)),
] {
let ways_in = TrustedDeviceWaysIn {
device_is_trusted,
has_master_password,
has_admin_approval,
can_create_account,
};
assert_eq!(
ways_in.offer(enabled),
expected,
"{enabled} {device_is_trusted} {has_master_password} {has_admin_approval} {can_create_account}"
);
}
}
}

9
src/api/notifications.rs

@ -529,11 +529,14 @@ impl WebSocketUsers {
} }
} }
/// `acting_device` is the device of this user that answered the request, and is the one left out
/// of the notification. An answer that came from outside their devices, as an approval by an
/// administrator of their organization does, passes `None` so that all of them hear about it.
pub async fn send_auth_response( pub async fn send_auth_response(
&self, &self,
user_id: &UserId, user_id: &UserId,
auth_request_id: &AuthRequestId, auth_request_id: &AuthRequestId,
device: &Device, acting_device: Option<&Device>,
conn: &DbConn, conn: &DbConn,
) { ) {
// Skip any processing if both WebSockets and Push are not active // Skip any processing if both WebSockets and Push are not active
@ -543,14 +546,14 @@ impl WebSocketUsers {
let data = create_update( let data = create_update(
vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())], vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())],
UpdateType::AuthRequestResponse, UpdateType::AuthRequestResponse,
Some(device.uuid.clone()), acting_device.map(|device| device.uuid.clone()),
); );
if CONFIG.enable_websocket() { if CONFIG.enable_websocket() {
self.send_update(user_id, &data).await; self.send_update(user_id, &data).await;
} }
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
push_auth_response(user_id, auth_request_id, device, conn).await; push_auth_response(user_id, auth_request_id, acting_device, conn).await;
} }
} }
} }

15
src/api/push.rs

@ -317,13 +317,22 @@ pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device:
} }
} }
pub async fn push_auth_response(user_id: &UserId, auth_request_id: &AuthRequestId, device: &Device, conn: &DbConn) { /// `acting_device` is the device that answered and the one left out of the notification, since it
/// already knows. An answer that did not come from a device of this user, as an administrator approval
/// does, leaves it out: naming a foreign device would hand its identifiers to the push relay under the
/// wrong user id and tell the wrong device to ignore the answer.
pub async fn push_auth_response(
user_id: &UserId,
auth_request_id: &AuthRequestId,
acting_device: Option<&Device>,
conn: &DbConn,
) {
if Device::check_user_has_push_device(user_id, conn).await { if Device::check_user_has_push_device(user_id, conn).await {
tokio::task::spawn(send_to_push_relay(json!({ tokio::task::spawn(send_to_push_relay(json!({
"userId": user_id, "userId": user_id,
"organizationId": null, "organizationId": null,
"deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) "deviceId": acting_device.and_then(|device| device.push_uuid.as_ref()), // Should be the records unique uuid of the acting device (unique uuid per user/device)
"identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) "identifier": acting_device.map(|device| &device.uuid), // Should be the acting device id (aka uuid per device/app)
"type": UpdateType::AuthRequestResponse as i32, "type": UpdateType::AuthRequestResponse as i32,
"payload": { "payload": {
"userId": user_id, "userId": user_id,

34
src/auth.rs

@ -849,6 +849,40 @@ impl<'r> FromRequest<'r> for AdminHeaders {
} }
} }
/// A member who may act on the account recovery of an organization, which is also what answering its
/// device approvals comes down to.
///
/// Upstream guards those endpoints on a permission, `ManageResetPassword`, rather than on a role, so this
/// asks `Membership::can_manage_reset_password_now` instead of naming roles here. Today that permission
/// belongs to the administrators alone, making this the same set of callers as `AdminHeaders`; keeping it
/// apart is what lets a custom role hold the permission later without revisiting every endpoint.
/// https://github.com/bitwarden/server/blob/main/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs
pub struct ManageResetPasswordHeaders {
pub device: Device,
pub user: User,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ManageResetPasswordHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if headers.membership.can_manage_reset_password_now() {
Outcome::Success(Self {
device: headers.device,
user: headers.user,
ip: headers.ip,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need permission to manage account recovery to call this endpoint")
}
}
}
// col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"), // col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"),
// but there could be cases where it is a query value. // but there could be cases where it is a query value.
// First check the path, if this is not a valid uuid, try the query values. // First check the path, if this is not a valid uuid, try the query values.

10
src/config.rs

@ -847,6 +847,8 @@ make_config! {
sso_client_cache_expiration: u64, true, def, 0; sso_client_cache_expiration: u64, true, def, 0;
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
sso_debug_tokens: bool, true, def, false; sso_debug_tokens: bool, true, def, false;
/// Trusted device encryption |> Let users unlock their vault after an SSO login with a key stored on a trusted device instead of a master password. A user who never sets a master password and then loses every trusted device cannot recover their vault. See: https://bitwarden.com/help/login-with-sso-trusted-devices/
sso_trusted_device_encryption: bool, true, def, false;
}, },
/// Yubikey settings /// Yubikey settings
@ -1114,6 +1116,12 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
validate_internal_sso_issuer_url(&cfg.sso_authority)?; validate_internal_sso_issuer_url(&cfg.sso_authority)?;
validate_internal_sso_redirect_url(&cfg.sso_callback_path)?; validate_internal_sso_redirect_url(&cfg.sso_callback_path)?;
validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?;
} else if cfg.sso_trusted_device_encryption {
err!(
"`SSO_TRUSTED_DEVICE_ENCRYPTION` requires `SSO_ENABLED` to be set, it only applies to SSO logins. \
To stop offering trusted devices, clear `SSO_TRUSTED_DEVICE_ENCRYPTION` and leave `SSO_ENABLED` on \
until every user without a master password has set one, otherwise they can no longer log in at all"
)
} }
if cfg._enable_yubico { if cfg._enable_yubico {
@ -1754,6 +1762,7 @@ where
reg!("email/change_email_invited", ".html"); reg!("email/change_email_invited", ".html");
reg!("email/change_email", ".html"); reg!("email/change_email", ".html");
reg!("email/delete_account", ".html"); reg!("email/delete_account", ".html");
reg!("email/device_approval_requested", ".html");
reg!("email/emergency_access_invite_accepted", ".html"); reg!("email/emergency_access_invite_accepted", ".html");
reg!("email/emergency_access_invite_confirmed", ".html"); reg!("email/emergency_access_invite_confirmed", ".html");
reg!("email/emergency_access_recovery_approved", ".html"); reg!("email/emergency_access_recovery_approved", ".html");
@ -1775,6 +1784,7 @@ where
reg!("email/send_single_org_removed_from_org", ".html"); reg!("email/send_single_org_removed_from_org", ".html");
reg!("email/smtp_test", ".html"); reg!("email/smtp_test", ".html");
reg!("email/sso_change_email", ".html"); reg!("email/sso_change_email", ".html");
reg!("email/trusted_device_admin_approval", ".html");
reg!("email/twofactor_email", ".html"); reg!("email/twofactor_email", ".html");
reg!("email/verify_email", ".html"); reg!("email/verify_email", ".html");
reg!("email/welcome_must_verify", ".html"); reg!("email/welcome_must_verify", ".html");

272
src/db/models/auth_request.rs

@ -1,4 +1,4 @@
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, TimeDelta, Utc};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*; use diesel::prelude::*;
use serde_json::Value; use serde_json::Value;
@ -12,7 +12,7 @@ use crate::{
}; };
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{DeviceId, OrganizationId, UserId}; use super::{DeviceId, DeviceType, MembershipId, OrganizationId, UserId};
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)] #[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)]
#[diesel(table_name = auth_requests)] #[diesel(table_name = auth_requests)]
@ -22,6 +22,8 @@ pub struct AuthRequest {
pub uuid: AuthRequestId, pub uuid: AuthRequestId,
pub user_uuid: UserId, pub user_uuid: UserId,
pub organization_uuid: Option<OrganizationId>, pub organization_uuid: Option<OrganizationId>,
/// See `AuthRequestType`. Decides who may answer the request and how long it stays open.
pub atype: i32,
pub request_device_identifier: DeviceId, pub request_device_identifier: DeviceId,
pub device_type: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs pub device_type: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs
@ -42,9 +44,50 @@ pub struct AuthRequest {
pub authentication_date: Option<NaiveDateTime>, pub authentication_date: Option<NaiveDateTime>,
} }
/// https://github.com/bitwarden/server/blob/main/src/Core/Auth/Enums/AuthRequestType.cs
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AuthRequestType {
/// A new session asking one of the user's own devices to let it in.
AuthenticateAndUnlock = 0,
/// An existing session asking one of the user's own devices to unlock it.
Unlock = 1,
/// The user asking an administrator of their organization to let a device in, for when no
/// device of their own is around to ask.
AdminApproval = 2,
}
impl AuthRequestType {
pub fn from_i32(value: i32) -> Option<Self> {
match value {
0 => Some(AuthRequestType::AuthenticateAndUnlock),
1 => Some(AuthRequestType::Unlock),
2 => Some(AuthRequestType::AdminApproval),
_ => None,
}
}
}
impl AuthRequest { impl AuthRequest {
/// A request between the user's own devices is short lived, an administrator gets a week to
/// answer, and their answer stays usable for half a day. Same windows as upstream.
/// https://github.com/bitwarden/server/blob/main/src/Core/Settings/GlobalSettings.cs
pub fn user_request_expiration() -> TimeDelta {
TimeDelta::try_minutes(15).unwrap()
}
pub fn admin_request_expiration() -> TimeDelta {
TimeDelta::try_days(7).unwrap()
}
pub fn after_admin_approval_expiration() -> TimeDelta {
TimeDelta::try_hours(12).unwrap()
}
#[expect(clippy::too_many_arguments, reason = "Every field of the request is supplied by the caller")]
pub fn new( pub fn new(
user_uuid: UserId, user_uuid: UserId,
organization_uuid: Option<OrganizationId>,
atype: AuthRequestType,
request_device_identifier: DeviceId, request_device_identifier: DeviceId,
device_type: i32, device_type: i32,
request_ip: String, request_ip: String,
@ -56,7 +99,8 @@ impl AuthRequest {
Self { Self {
uuid: AuthRequestId(crate::util::get_uuid()), uuid: AuthRequestId(crate::util::get_uuid()),
user_uuid, user_uuid,
organization_uuid: None, organization_uuid,
atype: atype as i32,
request_device_identifier, request_device_identifier,
device_type, device_type,
@ -73,12 +117,53 @@ impl AuthRequest {
} }
} }
pub fn is_admin_approval(&self) -> bool {
self.atype == AuthRequestType::AdminApproval as i32
}
pub fn is_expired(&self) -> bool {
let now = Utc::now().naive_utc();
if self.is_admin_approval() {
// Once approved the clock restarts, so the user has time to come back and use it.
if let (Some(true), Some(response_date)) = (self.approved, self.response_date) {
return now > response_date + Self::after_admin_approval_expiration();
}
return now > self.creation_date + Self::admin_request_expiration();
}
now > self.creation_date + Self::user_request_expiration()
}
pub fn to_json_for_pending_device(&self) -> Value { pub fn to_json_for_pending_device(&self) -> Value {
json!({ json!({
"id": self.uuid, "id": self.uuid,
"creationDate": format_date(&self.creation_date), "creationDate": format_date(&self.creation_date),
}) })
} }
/// What an administrator gets to see about a request waiting for them: the public key of the asking
/// device and enough about it to recognise it. Same shape as
/// `PendingOrganizationAuthRequestResponseModel` upstream.
///
/// Deliberately no access code, which is the asking device's own proof, and no wrapped key: a waiting
/// request has none, and handing one out here would be crypto material the answering side cannot use.
pub fn to_json_for_organization(&self, email: &str, member_id: &MembershipId) -> Value {
json!({
"id": self.uuid,
"userId": self.user_uuid,
"organizationUserId": member_id,
"email": email,
"publicKey": self.public_key,
"requestDeviceIdentifier": self.request_device_identifier,
"requestDeviceType": DeviceType::from_i32(self.device_type).to_string(),
"requestIpAddress": self.request_ip,
// Not recorded here, but the clients read it, so it is answered rather than missing.
"requestCountryName": null,
"creationDate": format_date(&self.creation_date),
"object": "pending-org-auth-request",
})
}
} }
impl AuthRequest { impl AuthRequest {
@ -130,16 +215,55 @@ impl AuthRequest {
.await .await
} }
/// The request a device is currently waiting on, if it is still open and within its window.
///
/// Only the types a device answers for itself. A request addressed to an administrator is answered
/// through the organization and stays open for a week, so counting it here would let it shadow the
/// short lived request the user is actually being shown.
/// https://github.com/bitwarden/server/blob/main/src/Infrastructure.EntityFramework/Auth/Repositories/Queries/DeviceWithPendingAuthByUserIdQuery.cs
pub async fn find_by_user_and_requested_device( pub async fn find_by_user_and_requested_device(
user_uuid: &UserId, user_uuid: &UserId,
device_uuid: &DeviceId, device_uuid: &DeviceId,
conn: &DbConn, conn: &DbConn,
) -> Option<Self> { ) -> Option<Self> {
let oldest = Utc::now().naive_utc() - Self::user_request_expiration();
conn.run(move |conn| {
auth_requests::table
.filter(auth_requests::user_uuid.eq(user_uuid))
.filter(auth_requests::request_device_identifier.eq(device_uuid))
.filter(auth_requests::atype.ne(AuthRequestType::AdminApproval as i32))
.filter(auth_requests::approved.is_null())
.filter(auth_requests::creation_date.gt(oldest))
.order_by(auth_requests::creation_date.desc())
.first::<Self>(conn)
.ok()
})
.await
}
/// The open request a device already has waiting at this organization, if any.
///
/// Asking again from the same device updates that one instead of adding another, so a retrying client
/// cannot fill the table or mail the administrators over and over. A request past its window does not
/// count: nobody can answer it any more, and reviving it by moving its date forward would leave the
/// user waiting on a request the administrators were never told about. Asking again is a new request.
pub async fn find_pending_admin_approval(
user_uuid: &UserId,
device_uuid: &DeviceId,
org_uuid: &OrganizationId,
conn: &DbConn,
) -> Option<Self> {
let oldest = Utc::now().naive_utc() - Self::admin_request_expiration();
conn.run(move |conn| { conn.run(move |conn| {
auth_requests::table auth_requests::table
.filter(auth_requests::user_uuid.eq(user_uuid)) .filter(auth_requests::user_uuid.eq(user_uuid))
.filter(auth_requests::request_device_identifier.eq(device_uuid)) .filter(auth_requests::request_device_identifier.eq(device_uuid))
.filter(auth_requests::organization_uuid.eq(org_uuid))
.filter(auth_requests::atype.eq(AuthRequestType::AdminApproval as i32))
.filter(auth_requests::approved.is_null()) .filter(auth_requests::approved.is_null())
.filter(auth_requests::creation_date.gt(oldest))
.order_by(auth_requests::creation_date.desc()) .order_by(auth_requests::creation_date.desc())
.first::<Self>(conn) .first::<Self>(conn)
.ok() .ok()
@ -147,16 +271,38 @@ impl AuthRequest {
.await .await
} }
pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec<Self> { /// Everything an administrator of this organization still has to answer.
pub async fn find_pending_admin_approval_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| { conn.run(move |conn| {
auth_requests::table auth_requests::table
.filter(auth_requests::creation_date.lt(dt)) .filter(auth_requests::organization_uuid.eq(org_uuid))
.filter(auth_requests::atype.eq(AuthRequestType::AdminApproval as i32))
.filter(auth_requests::approved.is_null())
.order_by(auth_requests::creation_date.desc())
.load::<Self>(conn) .load::<Self>(conn)
.expect("Error loading auth_requests") .expect("Error loading auth_requests")
}) })
.await .await
} }
/// Bound to the organization on purpose: an administrator may only ever reach a request that
/// was addressed to their own organization.
pub async fn find_admin_approval_by_org_and_uuid(
uuid: &AuthRequestId,
org_uuid: &OrganizationId,
conn: &DbConn,
) -> Option<Self> {
conn.run(move |conn| {
auth_requests::table
.filter(auth_requests::uuid.eq(uuid))
.filter(auth_requests::organization_uuid.eq(org_uuid))
.filter(auth_requests::atype.eq(AuthRequestType::AdminApproval as i32))
.first::<Self>(conn)
.ok()
})
.await
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult { pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| { conn.run(move |conn| {
diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid))) diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid)))
@ -170,12 +316,54 @@ impl AuthRequest {
ct_eq(&self.access_code, access_code) ct_eq(&self.access_code, access_code)
} }
/// Drops everything past its window, which is a different one per type. One statement per case rather
/// than reading the table and deleting row by row, so the work stays in the database.
/// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
pub async fn purge_expired_auth_requests(conn: &DbConn) { pub async fn purge_expired_auth_requests(conn: &DbConn) {
// delete auth requests older than 15 minutes which is functionally equivalent to upstream: let now = Utc::now().naive_utc();
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql let admin = AuthRequestType::AdminApproval as i32;
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap();
for auth_request in Self::find_created_before(&expiry_time, conn).await { let between_devices = now - Self::user_request_expiration();
auth_request.delete(conn).await.ok(); let for_an_admin = now - Self::admin_request_expiration();
let after_approval = now - Self::after_admin_approval_expiration();
let result = conn
.run(move |conn| -> EmptyResult {
// Between the user's own devices: 15 minutes from the moment it was asked.
let _: () = diesel::delete(
auth_requests::table
.filter(auth_requests::atype.ne(admin))
.filter(auth_requests::creation_date.lt(between_devices)),
)
.execute(conn)
.map_res("Error purging the expired auth requests")?;
// Approved by an administrator: half a day from the answer, so the user has time to
// come back and use it.
let _: () = diesel::delete(
auth_requests::table
.filter(auth_requests::atype.eq(admin))
.filter(auth_requests::approved.eq(true))
.filter(auth_requests::response_date.lt(after_approval)),
)
.execute(conn)
.map_res("Error purging the approved auth requests")?;
// Waiting for an administrator, or refused by one: a week from the moment it was
// asked either way, a refusal does not extend anything.
diesel::delete(
auth_requests::table
.filter(auth_requests::atype.eq(admin))
.filter(auth_requests::approved.is_null().or(auth_requests::approved.eq(false)))
.filter(auth_requests::creation_date.lt(for_an_admin)),
)
.execute(conn)
.map_res("Error purging the unanswered auth requests")
})
.await;
if let Err(e) = result {
error!("Error purging the expired auth requests: {e:#?}");
} }
} }
} }
@ -197,3 +385,67 @@ impl AuthRequest {
UuidFromParam, UuidFromParam,
)] )]
pub struct AuthRequestId(String); pub struct AuthRequestId(String);
#[cfg(test)]
mod tests {
use super::*;
fn request(atype: AuthRequestType, age: TimeDelta) -> AuthRequest {
let mut auth_request = AuthRequest::new(
String::from("user").into(),
None,
atype,
String::from("device").into(),
9,
String::from("127.0.0.1"),
String::from("code"),
String::from("2.public"),
);
auth_request.creation_date = Utc::now().naive_utc() - age;
auth_request
}
#[test]
fn each_request_type_expires_after_its_own_window() {
assert!(!request(AuthRequestType::AuthenticateAndUnlock, TimeDelta::try_minutes(14).unwrap()).is_expired());
assert!(request(AuthRequestType::AuthenticateAndUnlock, TimeDelta::try_minutes(16).unwrap()).is_expired());
assert!(request(AuthRequestType::Unlock, TimeDelta::try_minutes(16).unwrap()).is_expired());
// An administrator gets a week. A request nobody answered in it must not come back as pending (see
// `find_pending_admin_approval`), that would leave the user waiting on something nobody was told of.
let week = TimeDelta::try_days(7).unwrap();
assert!(!request(AuthRequestType::AdminApproval, week - TimeDelta::minutes(1)).is_expired());
assert!(request(AuthRequestType::AdminApproval, week + TimeDelta::seconds(1)).is_expired());
}
#[test]
fn the_answer_of_an_administrator_starts_its_own_clock() {
// Answered right at the end of the week, so the request itself is long past its window.
let mut auth_request = request(AuthRequestType::AdminApproval, TimeDelta::try_days(7).unwrap());
auth_request.approved = Some(true);
auth_request.response_date = Some(Utc::now().naive_utc() - TimeDelta::try_hours(11).unwrap());
assert!(!auth_request.is_expired(), "the user still has time to come back and use it");
auth_request.response_date = Some(Utc::now().naive_utc() - TimeDelta::try_hours(13).unwrap());
assert!(auth_request.is_expired());
// A refusal does not extend anything, the request stays dead after its own window.
auth_request.approved = Some(false);
auth_request.response_date = Some(Utc::now().naive_utc());
assert!(auth_request.is_expired());
}
#[test]
fn only_the_admin_approval_type_is_answered_by_an_organization() {
assert!(request(AuthRequestType::AdminApproval, TimeDelta::zero()).is_admin_approval());
assert!(!request(AuthRequestType::Unlock, TimeDelta::zero()).is_admin_approval());
assert!(!request(AuthRequestType::AuthenticateAndUnlock, TimeDelta::zero()).is_admin_approval());
assert_eq!(AuthRequestType::from_i32(0), Some(AuthRequestType::AuthenticateAndUnlock));
assert_eq!(AuthRequestType::from_i32(1), Some(AuthRequestType::Unlock));
assert_eq!(AuthRequestType::from_i32(2), Some(AuthRequestType::AdminApproval));
assert_eq!(AuthRequestType::from_i32(3), None);
assert_eq!(AuthRequestType::from_i32(-1), None);
}
}

301
src/db/models/device.rs

@ -33,6 +33,16 @@ pub struct Device {
pub refresh_token: String, pub refresh_token: String,
pub twofactor_remember: Option<String>, pub twofactor_remember: Option<String>,
// Trusted device encryption. The client generates a key pair per device plus a device key
// that never leaves the device, and stores the three resulting blobs here:
/// The user key, encrypted with `encrypted_public_key`. This is the copy of the user key that
/// lets the device unlock the vault without a master password.
pub encrypted_user_key: Option<String>,
/// The device public key, encrypted with the user key.
pub encrypted_public_key: Option<String>,
/// The device private key, encrypted with the device key. The server never sees the device key.
pub encrypted_private_key: Option<String>,
} }
/// Local methods /// Local methods
@ -53,6 +63,10 @@ impl Device {
push_token: None, push_token: None,
refresh_token: Device::generate_refresh_token(), refresh_token: Device::generate_refresh_token(),
twofactor_remember: None, twofactor_remember: None,
encrypted_user_key: None,
encrypted_public_key: None,
encrypted_private_key: None,
} }
} }
@ -61,6 +75,46 @@ impl Device {
crypto::encode_random_bytes::<64>(&BASE64URL) crypto::encode_random_bytes::<64>(&BASE64URL)
} }
/// A stored key is only usable when it is actually there and non-empty.
fn present(key: Option<&String>) -> Option<&String> {
key.filter(|key| !key.is_empty())
}
fn key_json(key: Option<&String>) -> Value {
match Self::present(key) {
Some(key) => Value::String(key.clone()),
None => Value::Null,
}
}
/// Whether this device holds everything needed to unlock the vault on its own.
///
/// A client can drop its device key without telling us, so this only says that the server side
/// of the trust is complete. See `DeviceExtensions.IsTrusted` upstream.
pub fn is_trusted(&self) -> bool {
Self::present(self.encrypted_user_key.as_ref()).is_some()
&& Self::present(self.encrypted_public_key.as_ref()).is_some()
&& Self::present(self.encrypted_private_key.as_ref()).is_some()
}
/// The wrapped user key, but only while the whole trust is intact. Handing out one half of an
/// incomplete set would just make the client fail later in the unlock.
pub fn trusted_user_key(&self) -> Option<&String> {
self.is_trusted().then_some(self.encrypted_user_key.as_ref()).flatten()
}
pub fn trusted_private_key(&self) -> Option<&String> {
self.is_trusted().then_some(self.encrypted_private_key.as_ref()).flatten()
}
/// Whether the device still holds the private key of its own key pair.
///
/// That key is wrapped with the device key, which a user key rotation does not touch, so it outlives
/// one. It decides whether a device can be handed a freshly wrapped user key and be trusted again.
pub fn holds_private_key(&self) -> bool {
Self::present(self.encrypted_private_key.as_ref()).is_some()
}
pub fn to_json(&self) -> Value { pub fn to_json(&self) -> Value {
json!({ json!({
"id": self.uuid, "id": self.uuid,
@ -68,11 +122,28 @@ impl Device {
"type": self.atype, "type": self.atype,
"identifier": self.uuid, "identifier": self.uuid,
"creationDate": format_date(&self.created_at), "creationDate": format_date(&self.created_at),
"isTrusted": false, "isTrusted": self.is_trusted(),
"encryptedUserKey": Self::key_json(self.encrypted_user_key.as_ref()),
"encryptedPublicKey": Self::key_json(self.encrypted_public_key.as_ref()),
"object":"device" "object":"device"
}) })
} }
/// Response of `POST /devices/<identifier>/retrieve-keys`, used by the clients to re-wrap the
/// user key for every trusted device during a key rotation.
pub fn to_protected_json(&self) -> Value {
json!({
"id": self.uuid,
"name": self.name,
"type": self.atype,
"identifier": self.uuid,
"creationDate": format_date(&self.created_at),
"encryptedUserKey": Self::key_json(self.encrypted_user_key.as_ref()),
"encryptedPublicKey": Self::key_json(self.encrypted_public_key.as_ref()),
"object": "protectedDevice"
})
}
pub fn refresh_twofactor_remember(&mut self) -> String { pub fn refresh_twofactor_remember(&mut self) -> String {
use crate::auth::{encode_jwt, generate_2fa_remember_claims}; use crate::auth::{encode_jwt, generate_2fa_remember_claims};
@ -123,9 +194,9 @@ impl DeviceWithAuthRequest {
"identifier": self.device.uuid, "identifier": self.device.uuid,
"creationDate": format_date(&self.device.created_at), "creationDate": format_date(&self.device.created_at),
"devicePendingAuthRequest": auth_request, "devicePendingAuthRequest": auth_request,
"isTrusted": false, "isTrusted": self.device.is_trusted(),
"encryptedPublicKey": null, "encryptedPublicKey": Device::key_json(self.device.encrypted_public_key.as_ref()),
"encryptedUserKey": null, "encryptedUserKey": Device::key_json(self.device.encrypted_user_key.as_ref()),
"object": "device", "object": "device",
}) })
} }
@ -180,6 +251,152 @@ impl Device {
.await .await
} }
/// Invalidates every copy of the user key that is wrapped for one of the user's devices.
///
/// Called when the user key is replaced and the client did not say what to put in their place, so
/// those copies point at a key that no longer unlocks anything. No device counts as trusted
/// afterwards, so a client that stops here gets an extra login rather than a broken unlock. The
/// device key pairs are left alone: wrapped with the untouched device key, so
/// `POST /devices/update-trust` can hand every device the new user key and restore its trust.
/// One statement, so there is no half applied state.
pub async fn invalidate_wrapped_user_keys(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::update(devices::table.filter(devices::user_uuid.eq(user_uuid)))
.set((
devices::encrypted_user_key.eq::<Option<String>>(None),
devices::encrypted_public_key.eq::<Option<String>>(None),
))
.execute(conn)
.map_res("Error invalidating the wrapped user keys of the devices")
})
.await
}
/// Drops every stored key of the named devices, in one statement so it cannot half apply.
///
/// The caller has already checked that each id belongs to this user.
pub async fn untrust_many(user_uuid: &UserId, device_ids: Vec<DeviceId>, conn: &DbConn) -> EmptyResult {
if device_ids.is_empty() {
return Ok(());
}
conn.run(move |conn| {
diesel::update(
devices::table.filter(devices::user_uuid.eq(user_uuid)).filter(devices::uuid.eq_any(device_ids)),
)
.set((
devices::encrypted_user_key.eq::<Option<String>>(None),
devices::encrypted_public_key.eq::<Option<String>>(None),
devices::encrypted_private_key.eq::<Option<String>>(None),
))
.execute(conn)
.map_res("Error untrusting the devices")
})
.await
}
/// Replaces the trust of every device of the user in one go: the listed ones are re-wrapped for the
/// current user key, everything else loses whatever it still holds.
///
/// This is what a key rotation comes down to; the caller has already validated the ids, so this only
/// writes. One transaction, so the devices cannot be left split between the old and the new user key,
/// a state no client can tell apart from a working one. `POST /devices/update-trust` is narrower and
/// takes `update_trust` instead.
pub async fn replace_trust(
user_uuid: &UserId,
updates: Vec<(DeviceId, String, String)>,
conn: &DbConn,
) -> EmptyResult {
conn.run(move |conn| {
conn.transaction(|conn| -> EmptyResult {
let keep: Vec<DeviceId> = updates.iter().map(|(device_id, ..)| device_id.clone()).collect();
// Whatever the untouched devices hold wraps the previous user key, or is one half of
// a trust that was never finished. Either way it unlocks nothing and must not stay.
let cleared = (
devices::encrypted_user_key.eq::<Option<String>>(None),
devices::encrypted_public_key.eq::<Option<String>>(None),
devices::encrypted_private_key.eq::<Option<String>>(None),
);
let outdated = devices::table.filter(devices::user_uuid.eq(&user_uuid));
let _: () = if keep.is_empty() {
diesel::update(outdated).set(cleared).execute(conn)
} else {
diesel::update(outdated.filter(devices::uuid.ne_all(keep))).set(cleared).execute(conn)
}
.map_res("Error untrusting the devices left out of the rotation")?;
// The device key pair is deliberately not touched here: it is wrapped with the
// device key, which the server never sees and a rotation never changes.
for (device_id, encrypted_user_key, encrypted_public_key) in updates {
let _: () = diesel::update(
devices::table.filter(devices::uuid.eq(device_id)).filter(devices::user_uuid.eq(&user_uuid)),
)
.set((
devices::encrypted_user_key.eq(Some(encrypted_user_key)),
devices::encrypted_public_key.eq(Some(encrypted_public_key)),
))
.execute(conn)
.map_res("Error rotating the wrapped user key of a device")?;
}
Ok(())
})
})
.await
}
/// Writes what `POST /devices/update-trust` asked for: the listed devices are re-wrapped for the
/// current user key, and the devices named in `untrusted` lose everything they hold.
///
/// Anything in neither list is left exactly as it is, which is what separates this from
/// `replace_trust`. After a key rotation by a client too old to send `deviceKeyUnlockData` that is a
/// device still holding its own key pair, the only thing a later call could restore its trust from.
/// Mirrors `DeviceService.UpdateDevicesTrustAsync` upstream, which skips the same devices.
/// https://github.com/bitwarden/server/blob/main/src/Core/Services/Implementations/DeviceService.cs
pub async fn update_trust(
user_uuid: &UserId,
updates: Vec<(DeviceId, String, String)>,
untrusted: Vec<DeviceId>,
conn: &DbConn,
) -> EmptyResult {
conn.run(move |conn| {
conn.transaction(|conn| -> EmptyResult {
if !untrusted.is_empty() {
let _: () = diesel::update(
devices::table
.filter(devices::user_uuid.eq(&user_uuid))
.filter(devices::uuid.eq_any(untrusted)),
)
.set((
devices::encrypted_user_key.eq::<Option<String>>(None),
devices::encrypted_public_key.eq::<Option<String>>(None),
devices::encrypted_private_key.eq::<Option<String>>(None),
))
.execute(conn)
.map_res("Error untrusting the devices left out of the update")?;
}
// The device key pair is deliberately not touched here: it is wrapped with the
// device key, which the server never sees and a new user key never changes.
for (device_id, encrypted_user_key, encrypted_public_key) in updates {
let _: () = diesel::update(
devices::table.filter(devices::uuid.eq(device_id)).filter(devices::user_uuid.eq(&user_uuid)),
)
.set((
devices::encrypted_user_key.eq(Some(encrypted_user_key)),
devices::encrypted_public_key.eq(Some(encrypted_public_key)),
))
.execute(conn)
.map_res("Error updating the wrapped user key of a device")?;
}
Ok(())
})
})
.await
}
pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> { pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| { conn.run(move |conn| {
devices::table devices::table
@ -379,6 +596,17 @@ impl DeviceType {
_ => DeviceType::UnknownBrowser, _ => DeviceType::UnknownBrowser,
} }
} }
/// Whether a device of this type can answer a login request from another device.
///
/// The SDK, the server and the CLIs have no interactive prompt to show the request in, so they are
/// the ones left out. Matches `LoginApprovingClientTypes` upstream (desktop, mobile, web, browser).
pub fn can_approve_login_requests(&self) -> bool {
!matches!(
self,
DeviceType::Sdk | DeviceType::Server | DeviceType::WindowsCLI | DeviceType::MacOsCLI | DeviceType::LinuxCLI
)
}
} }
#[derive( #[derive(
@ -388,3 +616,68 @@ pub struct DeviceId(String);
#[derive(Clone, Debug, DieselNewType, Display, From, FromForm, Serialize, Deserialize, UuidFromParam)] #[derive(Clone, Debug, DieselNewType, Display, From, FromForm, Serialize, Deserialize, UuidFromParam)]
pub struct PushId(pub String); pub struct PushId(pub String);
#[cfg(test)]
mod tests {
use super::*;
fn trusted_device() -> Device {
let mut device = Device::new(String::from("device").into(), String::from("user").into(), String::new(), 9);
device.encrypted_user_key = Some(String::from("2.user"));
device.encrypted_public_key = Some(String::from("2.public"));
device.encrypted_private_key = Some(String::from("2.private"));
device
}
/// Without all three keys the device is not trusted and hands out none of them, the public key
/// included: it is not part of the login response, but without it the other two are useless to the
/// client. An empty key is as good as a missing one.
#[test]
fn a_device_is_only_trusted_with_all_three_keys() {
let device = trusted_device();
assert!(device.is_trusted());
assert_eq!(device.trusted_user_key(), Some(&String::from("2.user")));
assert_eq!(device.trusted_private_key(), Some(&String::from("2.private")));
let keys: [fn(&mut Device) -> &mut Option<String>; 3] = [
|device| &mut device.encrypted_user_key,
|device| &mut device.encrypted_public_key,
|device| &mut device.encrypted_private_key,
];
for key in keys {
for missing in [None, Some(String::new())] {
let mut device = trusted_device();
*key(&mut device) = missing;
assert!(!device.is_trusted());
assert_eq!(device.trusted_user_key(), None);
assert_eq!(device.trusted_private_key(), None);
}
}
}
#[test]
fn a_rotation_leaves_the_device_key_pair_in_place() {
// What `invalidate_wrapped_user_keys` does: the wrapped user key and the public key go, the private
// key stays, because the device key that wraps it is untouched by a rotation.
let mut device = trusted_device();
device.encrypted_user_key = None;
device.encrypted_public_key = None;
assert!(device.holds_private_key(), "the device can still be handed a new user key");
device.encrypted_private_key = Some(String::new());
assert!(!device.holds_private_key(), "an empty key is as good as a missing one");
let device = Device::new(String::from("device").into(), String::from("user").into(), String::new(), 9);
assert!(!device.holds_private_key(), "a device that never had a trust holds nothing");
}
#[test]
fn only_interactive_clients_can_approve_a_login_request() {
for atype in 0..=26 {
let device_type = DeviceType::from_i32(atype);
let expected = !matches!(atype, 21..=25);
assert_eq!(device_type.can_approve_login_requests(), expected, "device type {atype} ({device_type})");
}
}
}

2
src/db/models/event.rs

@ -58,7 +58,7 @@ pub enum EventType {
// UserUpdatedTempPassword = 1008, // Not supported // UserUpdatedTempPassword = 1008, // Not supported
// UserMigratedKeyToKeyConnector = 1009, // Not supported // UserMigratedKeyToKeyConnector = 1009, // Not supported
UserRequestedDeviceApproval = 1010, UserRequestedDeviceApproval = 1010,
// UserTdeOffboardingPasswordSet = 1011, // Not supported UserTdeOffboardingPasswordSet = 1011,
// Cipher // Cipher
CipherCreated = 1100, CipherCreated = 1100,

2
src/db/models/mod.rs

@ -20,7 +20,7 @@ mod user;
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, AuthRequestType};
pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::cipher::{Cipher, CipherId, RepromptType};
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};

154
src/db/models/organization.rs

@ -274,6 +274,67 @@ impl Membership {
} }
} }
/// Whether this membership is in one of the active states rather than a revoked one.
///
/// Revoking shifts the status the membership is to be restored to out of the active range instead of
/// writing `Revoked`, so a revoked row reads `-128`, `-127` or `-126` and never `-1`. `from_i32` knows
/// only the three active values, which is exactly how `OrgHeaders` turns a revoked member away, so it
/// decides it here too. Comparing against `Revoked` would let every one of those values through.
pub fn is_active(&self) -> bool {
MembershipStatus::from_i32(self.status).is_some()
}
/// The role side of account recovery, without asking what the membership's standing is.
///
/// Upstream this is a permission of its own, `ManageResetPassword`, which a custom role can also be
/// granted. Vaultwarden folds custom roles into `Manager` and drops their permissions, so only the
/// administrators are left holding it. Asking here rather than comparing roles at each call site keeps
/// that decision in one place for when custom roles arrive.
/// https://github.com/bitwarden/server/blob/main/src/Core/Context/CurrentContext.cs
fn has_manage_reset_password_role(&self) -> bool {
MembershipType::from_i32(self.atype).is_some_and(|atype| atype >= MembershipType::Admin)
}
/// Whether this membership may act on the account recovery of the organization's members right now:
/// reset their master password, and answer the device approvals they ask their organization for.
///
/// This is the authorization question, so it asks for a fully established membership: an invitation
/// that was never accepted or is still waiting to be confirmed is not yet somebody the organization
/// has put in charge of its members' keys.
pub fn can_manage_reset_password_now(&self) -> bool {
self.status == MembershipStatus::Confirmed as i32 && self.has_manage_reset_password_role()
}
/// Whether a login should tell the client that this member is on the answering side of account
/// recovery, which is what makes it walk a member who has no master password into setting one.
///
/// Weaker than `can_manage_reset_password_now` on purpose: it decides what the account is told about
/// itself, not what it may do. Upstream answers it for every active membership, invited and accepted
/// included, because an administrator provisioned by their first SSO login holds the role before anyone
/// confirms them, and waiting would let them through the trusted device flow without ever being asked
/// for the master password their role requires. A revoked membership is not active and never counts.
/// https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs
pub fn has_manage_reset_password_role_for_tde(&self) -> bool {
self.is_active() && self.has_manage_reset_password_role()
}
/// Whether the administrators of this organization can let a new device of this member in.
///
/// Approving hands the member their own user key wrapped for the asking device; the only copy the
/// organization has is what enrolling into account recovery left behind, so without that key there is
/// nothing to approve with. Enrolling also turns an invitation into a membership in the trusted device
/// flow, so `Accepted` has to count and not just `Confirmed`: a member who set up trusted devices and
/// lost the device before being confirmed would otherwise have no way back into their vault. Upstream
/// lets any membership row through; both ends are kept out here, an invitation is not a membership yet
/// and a revoked one is not one anymore.
/// https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs
pub fn can_use_admin_approval(&self) -> bool {
matches!(
MembershipStatus::from_i32(self.status),
Some(MembershipStatus::Accepted | MembershipStatus::Confirmed)
) && self.reset_password_key.as_ref().is_some_and(|key| !key.is_empty())
}
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;
@ -1238,4 +1299,97 @@ mod tests {
assert!(MembershipType::Manager > MembershipType::User); assert!(MembershipType::Manager > MembershipType::User);
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap());
} }
/// Account recovery asks a membership two questions that differ on purpose: whether it may act on the
/// keys of the organization's members right now, which takes a confirmed administrator, and whether a
/// login should tell the account it holds that role. Every active membership of an administrator does,
/// so one provisioned by their first SSO login is asked for a master password straight away. Being told
/// to set one is not being allowed to reset somebody else's.
#[test]
fn account_recovery_asks_for_the_role_and_the_standing_separately() {
let mut membership = Membership::new(String::from("user").into(), String::from("org").into(), None);
for (atype, holds_role) in [
(MembershipType::Owner, true),
(MembershipType::Admin, true),
// The custom role is folded into the manager one, losing whatever permissions came with it,
// so it cannot be assumed to hold this one.
(MembershipType::Manager, false),
(MembershipType::User, false),
] {
membership.atype = atype as i32;
for status in [MembershipStatus::Invited, MembershipStatus::Accepted, MembershipStatus::Confirmed] {
let status = status as i32;
membership.status = status;
let confirmed = status == MembershipStatus::Confirmed as i32;
let case = format!("type {}, status {status}", atype as i32);
assert_eq!(membership.can_manage_reset_password_now(), holds_role && confirmed, "{case}");
assert_eq!(membership.has_manage_reset_password_role_for_tde(), holds_role, "{case}");
// Revoked never counts, whatever the role says.
assert!(membership.revoke(), "{case}");
assert!(!membership.can_manage_reset_password_now(), "revoked, {case}");
assert!(!membership.has_manage_reset_password_role_for_tde(), "revoked, {case}");
}
}
}
#[test]
fn a_revoked_membership_is_not_active_whatever_it_was_revoked_from() {
let mut membership = Membership::new(String::from("user").into(), String::from("org").into(), None);
for status in [MembershipStatus::Invited, MembershipStatus::Accepted, MembershipStatus::Confirmed] {
let status = status as i32;
membership.status = status;
assert!(membership.is_active(), "status {status}");
// Revoking keeps the status it is to be restored to and shifts it out of the active range, so
// what is stored is never `Revoked` itself. Comparing against that value would let these through.
assert!(membership.revoke(), "revoking status {status}");
assert!(!membership.is_active(), "revoked from {status}, stored as {}", membership.status);
}
// The value the responses show for a revoked membership does not count either.
membership.status = MembershipStatus::Revoked as i32;
assert!(!membership.is_active());
}
#[test]
fn admin_approval_needs_an_accepted_membership_and_an_enrollment() {
let mut membership = Membership::new(String::from("user").into(), String::from("org").into(), None);
for (status, enrolled, expected, why) in [
// The invitation was never taken up, so there is no membership to act for yet.
(MembershipStatus::Invited, true, false, "an invitation is not a membership"),
// Where the trusted device enrollment leaves a member until an administrator confirms
// them. Losing the device in that window must not cost them their vault.
(MembershipStatus::Accepted, true, true, "an accepted member enrolled in account recovery"),
(MembershipStatus::Confirmed, true, true, "a confirmed member enrolled in account recovery"),
// Belonging to the organization is not the point, holding the key it would answer with
// is; without an enrollment there is nothing an administrator could hand back.
(MembershipStatus::Accepted, false, false, "accepted, but not enrolled"),
(MembershipStatus::Confirmed, false, false, "confirmed, but not enrolled"),
] {
membership.status = status as i32;
membership.reset_password_key = enrolled.then(|| String::from("2.aXY=|Y2lwaGVy|bWFj"));
assert_eq!(membership.can_use_admin_approval(), expected, "{why}");
}
// Nothing to wrap the user key with, so the same as never having enrolled.
membership.status = MembershipStatus::Confirmed as i32;
membership.reset_password_key = Some(String::new());
assert!(!membership.can_use_admin_approval(), "an empty key is not an enrollment");
// Revoking takes the access away but leaves the key behind, which must not keep letting new devices
// in, whatever status the revoked membership is stored as.
membership.reset_password_key = Some(String::from("2.aXY=|Y2lwaGVy|bWFj"));
for was in [MembershipStatus::Accepted, MembershipStatus::Confirmed] {
let was = was as i32;
membership.status = was;
assert!(membership.revoke(), "revoking status {was}");
assert!(!membership.can_use_admin_approval(), "revoked from {was}, stored as {}", membership.status);
}
}
} }

4
src/db/schema.rs

@ -55,6 +55,9 @@ table! {
push_token -> Nullable<Text>, push_token -> Nullable<Text>,
refresh_token -> Text, refresh_token -> Text,
twofactor_remember -> Nullable<Text>, twofactor_remember -> Nullable<Text>,
encrypted_user_key -> Nullable<Text>,
encrypted_public_key -> Nullable<Text>,
encrypted_private_key -> Nullable<Text>,
} }
} }
@ -329,6 +332,7 @@ table! {
uuid -> Text, uuid -> Text,
user_uuid -> Text, user_uuid -> Text,
organization_uuid -> Nullable<Text>, organization_uuid -> Nullable<Text>,
atype -> Integer,
request_device_identifier -> Text, request_device_identifier -> Text,
device_type -> Integer, device_type -> Integer,
request_ip -> Text, request_ip -> Text,

51
src/mail.rs

@ -531,6 +531,57 @@ pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTi
send_email(address, &subject, body_html, body_text).await send_email(address, &subject, body_html, body_text).await
} }
/// Tells the administrators of an organization that one of their members is waiting to have a
/// device let in. Trusted device encryption falls back to this when the member has no other device
/// of their own left to ask.
pub async fn send_device_approval_requested(
address: &str,
org_id: &OrganizationId,
org_name: &str,
user_email: &str,
user_name: &str,
) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/device_approval_requested",
json!({
// Straight to the page that answers these, the same route the upstream admin console
// uses for them.
"url": format!("{}/#/organizations/{}/settings/device-approvals", CONFIG.domain(), org_id),
"img_src": CONFIG._smtp_img_src(),
"org_name": org_name,
"user_email": user_email,
"user_name": user_name,
}),
)?;
send_email(address, &subject, body_html, body_text).await
}
/// The other half of the above: the member learns that a device of theirs was let in, so an
/// approval they did not ask for does not pass unnoticed.
pub async fn send_trusted_device_admin_approval(
address: &str,
org_name: &str,
dt: &NaiveDateTime,
ip: &str,
device: &str,
) -> EmptyResult {
let fmt = "%A, %B %_d, %Y at %r %Z";
let (subject, body_html, body_text) = get_text(
"email/trusted_device_admin_approval",
json!({
"url": CONFIG.domain(),
"img_src": CONFIG._smtp_img_src(),
"org_name": org_name,
"datetime": crate::util::format_naive_datetime_local(dt, fmt),
"ip": ip,
"device": device,
}),
)?;
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_incomplete_2fa_login( pub async fn send_incomplete_2fa_login(
address: &str, address: &str,
ip: &str, ip: &str,

6
src/static/templates/email/device_approval_requested.hbs

@ -0,0 +1,6 @@
Device Approval Requested
<!---------------->
{{user_name}} ({{user_email}}) is asking to have a new device approved in your {{org_name}} organization. Until an administrator approves it, they cannot get into their vault on that device.
Review the request at {{{url}}}.
{{> email/email_footer_text }}

16
src/static/templates/email/device_approval_requested.html.hbs

@ -0,0 +1,16 @@
Device Approval Requested
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{user_name}}</b> ({{user_email}}) is asking to have a new device approved in your <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b> organization. Until an administrator approves it, they cannot get into their vault on that device.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
Review the request at <a href="{{{url}}}" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #175DDC; line-height: 25px; -webkit-font-smoothing: antialiased; text-decoration: underline; -webkit-text-size-adjust: none;">{{{url}}}</a>.
</td>
</tr>
</table>
{{> email/email_footer }}

9
src/static/templates/email/trusted_device_admin_approval.hbs

@ -0,0 +1,9 @@
Device Approved
<!---------------->
An administrator of your {{org_name}} organization approved a device for your account on {{datetime}}.
Device: {{device}}
IP address: {{ip}}
If this was not you, contact your administrator and remove the device from your account.
{{> email/email_footer_text }}

22
src/static/templates/email/trusted_device_admin_approval.html.hbs

@ -0,0 +1,22 @@
Device Approved
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
An administrator of your <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b> organization approved a device for your account on {{datetime}}.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
Device: {{device}}<br />
IP address: {{ip}}
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
If this was not you, contact your administrator and remove the device from your account.
</td>
</tr>
</table>
{{> email/email_footer }}

153
src/util.rs

@ -540,6 +540,159 @@ pub fn is_valid_email(email: &str) -> bool {
email_url.domain().is_some() && email_url.path() == "/" && email_url.query().is_none() email_url.domain().is_some() && email_url.path() == "/" && email_url.query().is_none()
} }
/// The most an `EncString` we are willing to store may weigh.
///
/// Upstream puts no length on the fields this guards; this is ours, so a client cannot park megabytes in
/// a column meant to hold a wrapped key. The largest legitimate value is a device's RSA-2048 private key
/// wrapped with AES-CBC plus a MAC, around 1.7 kB, so this leaves room to spare.
const MAX_ENC_STRING_LENGTH: usize = 4096;
/// The number of `|` separated parts an `EncString` of the given `EncryptionType` is made of.
///
/// - `3`/`4` Rsa2048_OaepSha256/Sha1_B64 and `7` XChaCha20Poly1305_B64 are one blob;
/// - `0` AesCbc256_B64 is `iv|ct`, `5`/`6` Rsa2048_Oaep*_HmacSha256_B64 are `rsaCt|mac`;
/// - `1`/`2` AesCbc128/256_HmacSha256_B64 are `iv|ct|mac`.
///
/// https://github.com/bitwarden/server/blob/main/src/Core/Enums/EncryptionType.cs
fn enc_string_parts(enc_type: u8) -> Option<usize> {
match enc_type {
3 | 4 | 7 => Some(1),
0 | 5 | 6 => Some(2),
1 | 2 => Some(3),
_ => None,
}
}
/// Whether a single part is base64, as permissively as upstream reads it.
///
/// Upstream accepts a final character whose unused padding bits are not zero, because such values exist
/// in the wild; a strict decoder rejects them. Everything else is the usual shape: a multiple of four
/// characters from the base64 alphabet, with at most two `=` closing it off.
/// https://github.com/bitwarden/server/blob/main/src/Core/Utilities/EncryptedStringAttribute.cs
fn is_valid_base64_permissive(value: &str) -> bool {
if value.is_empty() || !value.len().is_multiple_of(4) {
return false;
}
// A group of four holds at least two characters of data, so at most two may be padded away.
let data = value.strip_suffix("==").or_else(|| value.strip_suffix('=')).unwrap_or(value);
!data.is_empty() && data.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
}
/// Whether a value has the shape of a Bitwarden `EncString`: `<type>.<part>|<part>...`.
///
/// The server cannot tell whether a blob decrypts, but it can refuse everything that is not even of the
/// right form, which keeps unbounded junk out of the columns that hold key material. Mirrors
/// `EncryptedStringAttribute` upstream, including its header-less legacy form, but not its acceptance of
/// a type spelled out by name (`AesCbc256_B64.…`), which no client has ever written.
/// https://github.com/bitwarden/server/blob/main/src/Core/Utilities/EncryptedStringAttribute.cs
pub fn is_valid_enc_string(value: &str) -> bool {
if value.is_empty() || value.len() > MAX_ENC_STRING_LENGTH {
return false;
}
let (parts, data) = if let Some((enc_type, data)) = value.split_once('.') {
let Some(parts) = enc_type.parse::<u8>().ok().and_then(enc_string_parts) else {
return false;
};
(parts, data)
} else {
// Without a header the type is guessed from the number of parts, the same two candidates
// upstream picks between: three means it carries a MAC, anything else is read as iv|ct.
let parts = if value.matches('|').count() == 2 {
3
} else {
2
};
(parts, value)
};
let mut seen = 0;
for part in data.split('|') {
seen += 1;
if seen > parts || !is_valid_base64_permissive(part) {
return false;
}
}
seen == parts
}
#[cfg(test)]
mod enc_string_tests {
use super::is_valid_enc_string;
#[test]
fn a_well_formed_enc_string_of_every_type_is_accepted() {
for value in [
"0.aXY=|Y2lwaGVy", // AesCbc256_B64
"1.aXY=|Y2lwaGVy|bWFj", // AesCbc128_HmacSha256_B64
"2.aXY=|Y2lwaGVy|bWFj", // AesCbc256_HmacSha256_B64
"3.Y2lwaGVy", // Rsa2048_OaepSha256_B64
"4.Y2lwaGVy", // Rsa2048_OaepSha1_B64
"5.Y2lwaGVy|bWFj", // Rsa2048_OaepSha256_HmacSha256_B64
"6.Y2lwaGVy|bWFj", // Rsa2048_OaepSha1_HmacSha256_B64
"7.Y29zZWJ5dGVz", // XChaCha20Poly1305_B64, one blob of COSE bytes
"07.Y29zZWJ5dGVz", // the header is read as a number, not matched as text
"aXY=|Y2lwaGVy", // header-less legacy form, read as iv|ct
"aXY=|Y2lwaGVy|bWFj", // and as iv|ct|mac when it has three parts
"3.Y2lwaGVyLysvdGV4dA==", // the whole base64 alphabet, padded
"3.Y2lwaGVyLysvdGV4dGE=", // and with a single pad character
"3.QR==", // unused padding bits that are not zero: a strict decoder
"3.QUJDRR==", // refuses these, upstream deliberately does not, and such
"2.aXY=|QR==|bWFj", // values exist in the wild
] {
assert!(is_valid_enc_string(value), "{value}");
}
}
#[test]
fn anything_that_is_not_one_is_refused() {
for value in [
"",
" ",
"not-an-enc-string",
"2",
"2.",
".aXY=|Y2lwaGVy|bWFj",
"8.Y2lwaGVy", // no such type
"255.Y2lwaGVy", // nor at the top of the byte the header is read as
"256.Y2lwaGVy", // nor past it
"-1.Y2lwaGVy", // and none below zero either
"2.aXY=|Y2lwaGVy", // type 2 without its mac
"2.aXY=|Y2lwaGVy|bWFj|x", // or with one part too many
"4.Y2lwaGVy|bWFj", // type 4 carries no mac
"7.Y29zZQ==|bWFj", // and neither does type 7
"2.aXY=||bWFj", // an empty part is not base64
"4.not base64!",
"4.Y2lwaGV", // a length that is not a multiple of four
"4.Y2lwaGVy=", // a stray pad character breaks that length
"4.====", // padding only
"4.Y2lw=GVy", // padding in the middle
"4.Y2lw-GVy", // url-safe base64 is a different alphabet
"4.Y2lw GVy", // whitespace is not part of it either
"aXY=", // header-less, but only one part
"aXY=|Y2lwaGVy|bWFj|Zm91cg==", // header-less with four
] {
assert!(!is_valid_enc_string(value), "{value}");
}
}
#[test]
fn an_oversized_value_is_refused() {
let payload = "A".repeat(4096);
assert!(is_valid_enc_string(&format!("4.{}", &payload[..4000])));
assert!(!is_valid_enc_string(&format!("4.{payload}")), "must not grow without bound");
// A wrapped RSA-2048 device private key, the largest thing that legitimately arrives here,
// has to fit with room to spare.
let private_key = format!("2.{}|{}|{}", "A".repeat(24), "B".repeat(1652), "C".repeat(44));
assert!(private_key.len() < 2048);
assert!(is_valid_enc_string(&private_key));
}
}
// //
// Deployment environment methods // Deployment environment methods
// //

Loading…
Cancel
Save