From a6ccf0363c48d28523b5f44b8c41220c16fb10a2 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:51:31 +0200 Subject: [PATCH] Add trusted device encryption for SSO logins --- .env.template | 15 + .typos.toml | 6 +- .../down.sql | 20 + .../up.sql | 8 + .../down.sql | 8 + .../up.sql | 8 + .../down.sql | 8 + .../up.sql | 8 + src/api/core/accounts.rs | 830 ++++++++++++++++-- src/api/core/organizations.rs | 334 ++++++- src/api/core/sends.rs | 3 + src/api/identity.rs | 226 ++++- src/api/notifications.rs | 9 +- src/api/push.rs | 15 +- src/auth.rs | 34 + src/config.rs | 10 + src/db/models/auth_request.rs | 272 +++++- src/db/models/device.rs | 301 ++++++- src/db/models/event.rs | 2 +- src/db/models/mod.rs | 2 +- src/db/models/organization.rs | 154 ++++ src/db/schema.rs | 4 + src/mail.rs | 51 ++ .../email/device_approval_requested.hbs | 6 + .../email/device_approval_requested.html.hbs | 16 + .../email/trusted_device_admin_approval.hbs | 9 + .../trusted_device_admin_approval.html.hbs | 22 + src/util.rs | 153 ++++ 28 files changed, 2419 insertions(+), 115 deletions(-) create mode 100644 migrations/mysql/2026-07-31-120000_add_trusted_device_encryption/down.sql create mode 100644 migrations/mysql/2026-07-31-120000_add_trusted_device_encryption/up.sql create mode 100644 migrations/postgresql/2026-07-31-120000_add_trusted_device_encryption/down.sql create mode 100644 migrations/postgresql/2026-07-31-120000_add_trusted_device_encryption/up.sql create mode 100644 migrations/sqlite/2026-07-31-120000_add_trusted_device_encryption/down.sql create mode 100644 migrations/sqlite/2026-07-31-120000_add_trusted_device_encryption/up.sql create mode 100644 src/static/templates/email/device_approval_requested.hbs create mode 100644 src/static/templates/email/device_approval_requested.html.hbs create mode 100644 src/static/templates/email/trusted_device_admin_approval.hbs create mode 100644 src/static/templates/email/trusted_device_admin_approval.html.hbs diff --git a/.env.template b/.env.template index 62231776..7113559a 100644 --- a/.env.template +++ b/.env.template @@ -564,6 +564,21 @@ ## Log all the tokens, LOG_LEVEL=debug is required # 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 ### ######################## diff --git a/.typos.toml b/.typos.toml index 87c0c4a6..741a31ae 100644 --- a/.typos.toml +++ b/.typos.toml @@ -14,9 +14,9 @@ extend-ignore-re = [ # In SMTP it's called HELO, so ignore it "(?i)helo_name", "Server name sent during.+HELO", - # COSE Is short for CBOR Object Signing and Encryption, ignore these specific items - "COSEKey", - "COSEAlgorithm", + # COSE Is short for CBOR Object Signing and Encryption, which covers the type names taken from + # it as well as the acronym on its own + "COSE", # Ignore this specific string as it's valid "Ensure they are valid OTPs", # This word is misspelled upstream diff --git a/migrations/mysql/2026-07-31-120000_add_trusted_device_encryption/down.sql b/migrations/mysql/2026-07-31-120000_add_trusted_device_encryption/down.sql new file mode 100644 index 00000000..96a5dffd --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-31-120000_add_trusted_device_encryption/up.sql b/migrations/mysql/2026-07-31-120000_add_trusted_device_encryption/up.sql new file mode 100644 index 00000000..69787ac7 --- /dev/null +++ b/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); diff --git a/migrations/postgresql/2026-07-31-120000_add_trusted_device_encryption/down.sql b/migrations/postgresql/2026-07-31-120000_add_trusted_device_encryption/down.sql new file mode 100644 index 00000000..d022037d --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-31-120000_add_trusted_device_encryption/up.sql b/migrations/postgresql/2026-07-31-120000_add_trusted_device_encryption/up.sql new file mode 100644 index 00000000..69787ac7 --- /dev/null +++ b/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); diff --git a/migrations/sqlite/2026-07-31-120000_add_trusted_device_encryption/down.sql b/migrations/sqlite/2026-07-31-120000_add_trusted_device_encryption/down.sql new file mode 100644 index 00000000..d022037d --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-31-120000_add_trusted_device_encryption/up.sql b/migrations/sqlite/2026-07-31-120000_add_trusted_device_encryption/up.sql new file mode 100644 index 00000000..69787ac7 --- /dev/null +++ b/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); diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 8cc5e55b..43767523 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use chrono::Utc; use rocket::{ @@ -20,10 +20,10 @@ use crate::{ db::{ DbConn, DbPool, models::{ - AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, - EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership, - MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, - UserKdfType, + AuthRequest, AuthRequestId, AuthRequestType, Cipher, CipherId, Device, DeviceId, DeviceType, + DeviceWithAuthRequest, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, + Membership, MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, + UserId, UserKdfType, }, }, mail, @@ -45,6 +45,7 @@ pub fn routes() -> Vec { post_keys, post_password, post_set_password, + put_update_tde_offboarding_password, post_kdf, post_rotatekey, post_user_key, @@ -70,8 +71,15 @@ pub fn routes() -> Vec { put_device_token, put_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, post_auth_request, + post_admin_auth_request, get_auth_request, put_auth_request, get_auth_request_response, @@ -442,8 +450,11 @@ async fn post_set_password(data: Json, headers: Headers, conn: let data: SetPasswordData = data.into_inner(); let mut user = headers.user; - if user.private_key.is_some() { - err!("Account already initialized, cannot set password") + // A trusted device account already has its key pair but no master password, and must still be + // 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, @@ -451,6 +462,19 @@ async fn post_set_password(data: Json, headers: Headers, conn: let password_hint = clean_password_hint(data.master_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)?; user.set_password( @@ -463,7 +487,7 @@ async fn post_set_password(data: Json, headers: Headers, conn: .await?; 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.public_key = Some(keys.public_key); } @@ -500,6 +524,85 @@ async fn post_set_password(data: Json, 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, +} + +/// 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 = "")] +async fn put_update_tde_offboarding_password( + data: Json, + 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")] async fn profile(headers: Headers, conn: DbConn) -> Json { Json(headers.user.to_json(&conn).await) @@ -581,6 +684,24 @@ async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> Json 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.public_key = Some(data.public_key); @@ -798,6 +919,20 @@ struct RotateAccountUnlockData { emergency_access_unlock_data: Vec, master_password_unlock_data: MasterPasswordUnlockData, organization_account_recovery_unlock_data: Vec, + /// 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>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpdateDeviceKeysData { + device_id: DeviceId, + encrypted_user_key: String, + encrypted_public_key: String, } #[derive(Deserialize)] @@ -827,6 +962,52 @@ struct RotateAccountData { sends: Vec, } +/// 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> { + // 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//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( data: &KeyData, existing_ciphers: &[Cipher], @@ -931,6 +1112,7 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: // We only rotate the reset password key if it is set. existing_memberships.retain(|m| m.reset_password_key.is_some()); 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( &data, @@ -942,6 +1124,11 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: &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 for folder_data in data.account_data.folders { // Skip `null` folder id entries. @@ -1004,6 +1191,19 @@ async fn post_rotatekey(data: Json, 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 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 } +// 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//keys", data = "")] +async fn put_device_keys( + device_id: DeviceId, + data: Json, + 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//keys", data = "")] +async fn post_device_keys( + device_id: DeviceId, + data: Json, + 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//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, +} + +/// 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, +} + +/// 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, + existing_devices: &[Device], +) -> ApiResult { + validate_enc_strings(&[ + ("encryptedUserKey", ¤t_device.encrypted_user_key), + ("encryptedPublicKey", ¤t_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 = 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 = "")] +async fn post_devices_update_trust(data: Json, 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, +} + +#[post("/devices/untrust", data = "")] +async fn post_devices_untrust(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let data = data.into_inner(); + + let owned: HashSet = + 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")] fn get_tasks(_client_headers: ClientHeaders) -> JsonResult { Ok(Json(json!({ @@ -1604,9 +2045,57 @@ struct AuthRequestRequest { device_identifier: DeviceId, email: String, public_key: String, - // Not used for now - // #[serde(alias = "type")] - // _type: i32, + #[serde(default, rename = "type")] + atype: 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 = "")] @@ -1620,6 +2109,14 @@ async fn post_auth_request( 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 { 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"), }; + let Some(atype) = AuthRequestType::from_i32(data.atype) else { + err!("Unknown auth request type") + }; + let auth_request = AuthRequest::new( user.uuid.clone(), + None, + atype, data.device_identifier.clone(), client_headers.device_type, client_headers.ip.ip.to_string(), @@ -1651,19 +2154,136 @@ async fn post_auth_request( ) .await; - Ok(Json(json!({ - "id": auth_request.uuid, - "publicKey": auth_request.public_key, - "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), - "requestIpAddress": auth_request.request_ip, - "key": null, - "masterPasswordHash": null, - "creationDate": format_date(&auth_request.creation_date), - "responseDate": null, - "requestApproved": false, - "origin": CONFIG.domain_origin(), - "object": "auth-request" - }))) + Ok(Json(auth_request_json(&auth_request))) +} + +/// Asks the administrators of every organization the user belongs to to let this device in. +/// +/// The way out for someone who unlocks with trusted devices and has no other device left to ask. One +/// request per organization, so whichever administrator gets there first can answer. +/// https://github.com/bitwarden/server/blob/main/src/Api/Auth/Controllers/AuthRequestsController.cs +#[post("/auth-requests/admin-request", data = "")] +async fn post_admin_auth_request(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + // Every call mails all administrators of every organization involved, so it is worth its own limit. + 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::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/")] @@ -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") }; - 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!({ - "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" - }))) + Ok(Json(auth_request_json(&auth_request))) } #[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") }; + // 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 { 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") } + 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 = format_date(&response_date); if data.request_approved { auth_request.approved = Some(data.request_approved); @@ -1734,7 +2371,7 @@ async fn put_auth_request( auth_request.save(&conn).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( EventType::OrganizationUserApprovedAuthRequest as i32, @@ -1757,19 +2394,7 @@ async fn put_auth_request( .await; } - Ok(Json(json!({ - "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" - }))) + Ok(Json(auth_request_json(&auth_request))) } #[get("/auth-requests//response?")] @@ -1792,21 +2417,11 @@ async fn get_auth_request_response( 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!({ - "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" - }))) + Ok(Json(auth_request_json(&auth_request))) } // Now unused but not yet removed @@ -1823,7 +2438,8 @@ async fn get_auth_requests_pending(headers: Headers, conn: DbConn) -> JsonResult Ok(Json(json!({ "data": auth_requests .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| { 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"); } } + +#[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 { + 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//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}"); + } + } +} diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 36297d30..6144b925 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; +use chrono::Utc; use num_traits::FromPrimitive; use rocket::{Route, http::Status, serde::json::Json}; use serde_json::Value; @@ -8,17 +9,20 @@ use crate::{ CONFIG, api::admin::FAKE_ADMIN_UUID, api::{ - EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + AnonymousNotify, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, 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::{ DbConn, models::{ - Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, - Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, - UserId, + AuthRequest, AuthRequestId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, + CollectionUser, DeviceType, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, + MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, + OrganizationId, TwoFactor, TwoFactorType, User, UserId, }, }, mail, @@ -98,6 +102,10 @@ pub fn routes() -> Vec { get_reset_password_details, put_reset_password, put_recover_account, + get_organization_auth_requests, + deny_organization_auth_requests, + update_organization_auth_request, + update_many_organization_auth_requests, get_org_export, post_api_key, rotate_api_key, @@ -3187,7 +3195,13 @@ async fn put_reset_password_enrollment( 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 { master_password_hash: reset_request.master_password_hash, otp: reset_request.otp, @@ -3196,21 +3210,317 @@ async fn put_reset_password_enrollment( .await?; } - membership.reset_password_key = reset_password_key; - membership.save(&conn).await?; + let enrolled = reset_password_key.is_some(); + let membership_id = membership.uuid.clone(); - let event_type = if membership.reset_password_key.is_some() { + // 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.save(&conn).await?; + } + + // 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 } else { 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; 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//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, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct BulkDenyAuthRequestData { + ids: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OrganizationAuthRequestUpdateData { + id: AuthRequestId, + approved: bool, + key: Option, +} + +/// 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//auth-requests/", data = "", rank = 2)] +async fn update_organization_auth_request( + org_id: OrganizationId, + request_id: AuthRequestId, + data: Json, + 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//auth-requests/deny", data = "", rank = 1)] +async fn deny_organization_auth_requests( + org_id: OrganizationId, + data: Json, + 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//auth-requests", data = "")] +async fn update_many_organization_auth_requests( + org_id: OrganizationId, + data: Json>, + 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, + 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!! // 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. @@ -3248,7 +3558,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 { if rotate { 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 diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 042ce95b..d8ac5ac5 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -35,6 +35,9 @@ static ANON_PUSH_DEVICE: LazyLock = LazyLock::new(|| { push_token: None, refresh_token: String::new(), twofactor_remember: None, + encrypted_user_key: None, + encrypted_public_key: None, + encrypted_private_key: None, } }); diff --git a/src/api/identity.rs b/src/api/identity.rs index 6808ddde..54732c66 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -30,9 +30,9 @@ use crate::{ db::{ DbConn, models::{ - AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, - OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, - TwoFactorType, User, UserId, + AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, Membership, + OIDCCodeResponseError, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, SendId, + SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, }, }, error::MapResult, @@ -382,7 +382,7 @@ async fn sso_login( // We passed 2FA get auth tokens 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( @@ -504,7 +504,166 @@ async fn password_login( 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//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 { + // 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 { + 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( @@ -512,6 +671,7 @@ async fn authenticated_response( device: &mut Device, auth_tokens: auth::AuthTokens, twofactor_token: Option, + sso_login: bool, conn: &DbConn, ip: &ClientIp, ) -> JsonResult { @@ -573,6 +733,16 @@ async fn authenticated_response( 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!({ "access_token": auth_tokens.access_token(), "expires_in": auth_tokens.expires_in(), @@ -588,11 +758,7 @@ async fn authenticated_response( "MasterPasswordPolicy": master_password_policy, "scope": auth_tokens.scope(), "AccountKeys": account_keys, - "UserDecryptionOptions": { - "HasMasterPassword": has_master_password, - "MasterPasswordUnlock": master_password_unlock, - "Object": "userDecryptionOptions" - }, + "UserDecryptionOptions": user_decryption_options, }); 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))) } + +#[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}" + ); + } + } +} diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 8bfcd518..2cd6ecf8 100644 --- a/src/api/notifications.rs +++ b/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( &self, user_id: &UserId, auth_request_id: &AuthRequestId, - device: &Device, + acting_device: Option<&Device>, conn: &DbConn, ) { // Skip any processing if both WebSockets and Push are not active @@ -543,14 +546,14 @@ impl WebSocketUsers { let data = create_update( vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())], UpdateType::AuthRequestResponse, - Some(device.uuid.clone()), + acting_device.map(|device| device.uuid.clone()), ); if CONFIG.enable_websocket() { self.send_update(user_id, &data).await; } 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; } } } diff --git a/src/api/push.rs b/src/api/push.rs index e87a0985..e9315cdf 100644 --- a/src/api/push.rs +++ b/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 { tokio::task::spawn(send_to_push_relay(json!({ "userId": user_id, "organizationId": null, - "deviceId": device.push_uuid, // 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) + "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": acting_device.map(|device| &device.uuid), // Should be the acting device id (aka uuid per device/app) "type": UpdateType::AuthRequestResponse as i32, "payload": { "userId": user_id, diff --git a/src/auth.rs b/src/auth.rs index 07373389..fc068295 100644 --- a/src/auth.rs +++ b/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 { + 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//collections/"), // 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. diff --git a/src/config.rs b/src/config.rs index 9f0ae2e1..8764209f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -847,6 +847,8 @@ make_config! { sso_client_cache_expiration: u64, true, def, 0; /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required 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 @@ -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_redirect_url(&cfg.sso_callback_path)?; 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 { @@ -1754,6 +1762,7 @@ where reg!("email/change_email_invited", ".html"); reg!("email/change_email", ".html"); reg!("email/delete_account", ".html"); + reg!("email/device_approval_requested", ".html"); reg!("email/emergency_access_invite_accepted", ".html"); reg!("email/emergency_access_invite_confirmed", ".html"); reg!("email/emergency_access_recovery_approved", ".html"); @@ -1775,6 +1784,7 @@ where reg!("email/send_single_org_removed_from_org", ".html"); reg!("email/smtp_test", ".html"); reg!("email/sso_change_email", ".html"); + reg!("email/trusted_device_admin_approval", ".html"); reg!("email/twofactor_email", ".html"); reg!("email/verify_email", ".html"); reg!("email/welcome_must_verify", ".html"); diff --git a/src/db/models/auth_request.rs b/src/db/models/auth_request.rs index cc4b60fd..678a7022 100644 --- a/src/db/models/auth_request.rs +++ b/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 diesel::prelude::*; use serde_json::Value; @@ -12,7 +12,7 @@ use crate::{ }; use macros::UuidFromParam; -use super::{DeviceId, OrganizationId, UserId}; +use super::{DeviceId, DeviceType, MembershipId, OrganizationId, UserId}; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)] #[diesel(table_name = auth_requests)] @@ -22,6 +22,8 @@ pub struct AuthRequest { pub uuid: AuthRequestId, pub user_uuid: UserId, pub organization_uuid: Option, + /// See `AuthRequestType`. Decides who may answer the request and how long it stays open. + pub atype: i32, pub request_device_identifier: DeviceId, 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, } +/// 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 { + match value { + 0 => Some(AuthRequestType::AuthenticateAndUnlock), + 1 => Some(AuthRequestType::Unlock), + 2 => Some(AuthRequestType::AdminApproval), + _ => None, + } + } +} + 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( user_uuid: UserId, + organization_uuid: Option, + atype: AuthRequestType, request_device_identifier: DeviceId, device_type: i32, request_ip: String, @@ -56,7 +99,8 @@ impl AuthRequest { Self { uuid: AuthRequestId(crate::util::get_uuid()), user_uuid, - organization_uuid: None, + organization_uuid, + atype: atype as i32, request_device_identifier, 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 { json!({ "id": self.uuid, "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 { @@ -130,16 +215,55 @@ impl AuthRequest { .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( user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn, ) -> Option { + 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::(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 { + let oldest = Utc::now().naive_utc() - Self::admin_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::organization_uuid.eq(org_uuid)) + .filter(auth_requests::atype.eq(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::(conn) .ok() @@ -147,16 +271,38 @@ impl AuthRequest { .await } - pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { + /// 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 { conn.run(move |conn| { 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::(conn) .expect("Error loading auth_requests") }) .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 { + 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::(conn) + .ok() + }) + .await + } + pub async fn delete(&self, conn: &DbConn) -> EmptyResult { conn.run(move |conn| { 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) } + /// 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) { - // delete auth requests older than 15 minutes which is functionally equivalent to upstream: - // https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql - 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 { - auth_request.delete(conn).await.ok(); + let now = Utc::now().naive_utc(); + let admin = AuthRequestType::AdminApproval as i32; + + let between_devices = now - Self::user_request_expiration(); + 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, )] 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); + } +} diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 5e5f1f97..2afdaffb 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -33,6 +33,16 @@ pub struct Device { pub refresh_token: String, pub twofactor_remember: Option, + + // 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, + /// The device public key, encrypted with the user key. + pub encrypted_public_key: Option, + /// The device private key, encrypted with the device key. The server never sees the device key. + pub encrypted_private_key: Option, } /// Local methods @@ -53,6 +63,10 @@ impl Device { push_token: None, refresh_token: Device::generate_refresh_token(), 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) } + /// 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 { json!({ "id": self.uuid, @@ -68,11 +122,28 @@ impl Device { "type": self.atype, "identifier": self.uuid, "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" }) } + /// Response of `POST /devices//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 { use crate::auth::{encode_jwt, generate_2fa_remember_claims}; @@ -123,9 +194,9 @@ impl DeviceWithAuthRequest { "identifier": self.device.uuid, "creationDate": format_date(&self.device.created_at), "devicePendingAuthRequest": auth_request, - "isTrusted": false, - "encryptedPublicKey": null, - "encryptedUserKey": null, + "isTrusted": self.device.is_trusted(), + "encryptedPublicKey": Device::key_json(self.device.encrypted_public_key.as_ref()), + "encryptedUserKey": Device::key_json(self.device.encrypted_user_key.as_ref()), "object": "device", }) } @@ -180,6 +251,152 @@ impl Device { .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::>(None), + devices::encrypted_public_key.eq::>(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, 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::>(None), + devices::encrypted_public_key.eq::>(None), + devices::encrypted_private_key.eq::>(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 = 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::>(None), + devices::encrypted_public_key.eq::>(None), + devices::encrypted_private_key.eq::>(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, + 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::>(None), + devices::encrypted_public_key.eq::>(None), + devices::encrypted_private_key.eq::>(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 { conn.run(move |conn| { devices::table @@ -379,6 +596,17 @@ impl DeviceType { _ => 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( @@ -388,3 +616,68 @@ pub struct DeviceId(String); #[derive(Clone, Debug, DieselNewType, Display, From, FromForm, Serialize, Deserialize, UuidFromParam)] 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; 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})"); + } + } +} diff --git a/src/db/models/event.rs b/src/db/models/event.rs index cc0eb504..967aaa62 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -58,7 +58,7 @@ pub enum EventType { // UserUpdatedTempPassword = 1008, // Not supported // UserMigratedKeyToKeyConnector = 1009, // Not supported UserRequestedDeviceApproval = 1010, - // UserTdeOffboardingPasswordSet = 1011, // Not supported + UserTdeOffboardingPasswordSet = 1011, // Cipher CipherCreated = 1100, diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..f893022a 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -20,7 +20,7 @@ mod user; pub use self::archive::Archive; 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::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index d615a3fc..99dcec5a 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -277,6 +277,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 { if self.status < MembershipStatus::Invited as i32 { self.status += ACTIVATE_REVOKE_DIFF; @@ -1274,4 +1335,97 @@ mod tests { assert!(MembershipType::Manager > MembershipType::User); 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); + } + } } diff --git a/src/db/schema.rs b/src/db/schema.rs index 98b1eda6..0472ad95 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -55,6 +55,9 @@ table! { push_token -> Nullable, refresh_token -> Text, twofactor_remember -> Nullable, + encrypted_user_key -> Nullable, + encrypted_public_key -> Nullable, + encrypted_private_key -> Nullable, } } @@ -329,6 +332,7 @@ table! { uuid -> Text, user_uuid -> Text, organization_uuid -> Nullable, + atype -> Integer, request_device_identifier -> Text, device_type -> Integer, request_ip -> Text, diff --git a/src/mail.rs b/src/mail.rs index b20f2853..c915739f 100644 --- a/src/mail.rs +++ b/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 } +/// 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( address: &str, ip: &str, diff --git a/src/static/templates/email/device_approval_requested.hbs b/src/static/templates/email/device_approval_requested.hbs new file mode 100644 index 00000000..7c786775 --- /dev/null +++ b/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 }} diff --git a/src/static/templates/email/device_approval_requested.html.hbs b/src/static/templates/email/device_approval_requested.html.hbs new file mode 100644 index 00000000..580b89cb --- /dev/null +++ b/src/static/templates/email/device_approval_requested.html.hbs @@ -0,0 +1,16 @@ +Device Approval Requested + +{{> email/email_header }} + + + + + + + +
+ {{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 }} diff --git a/src/static/templates/email/trusted_device_admin_approval.hbs b/src/static/templates/email/trusted_device_admin_approval.hbs new file mode 100644 index 00000000..27dbd74a --- /dev/null +++ b/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 }} diff --git a/src/static/templates/email/trusted_device_admin_approval.html.hbs b/src/static/templates/email/trusted_device_admin_approval.html.hbs new file mode 100644 index 00000000..94a79717 --- /dev/null +++ b/src/static/templates/email/trusted_device_admin_approval.html.hbs @@ -0,0 +1,22 @@ +Device Approved + +{{> email/email_header }} + + + + + + + + + + +
+ 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 }} diff --git a/src/util.rs b/src/util.rs index 6de2d803..e482358c 100644 --- a/src/util.rs +++ b/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() } +/// 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 { + 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`: `.|...`. +/// +/// 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::().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 //