diff --git a/.env.template b/.env.template index 5f6f374c..0a94684c 100644 --- a/.env.template +++ b/.env.template @@ -560,6 +560,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_device_trusted_encryption/down.sql b/migrations/mysql/2026-07-31-120000_add_device_trusted_encryption/down.sql new file mode 100644 index 00000000..7bd8c602 --- /dev/null +++ b/migrations/mysql/2026-07-31-120000_add_device_trusted_encryption/down.sql @@ -0,0 +1,3 @@ +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_device_trusted_encryption/up.sql b/migrations/mysql/2026-07-31-120000_add_device_trusted_encryption/up.sql new file mode 100644 index 00000000..33ef1168 --- /dev/null +++ b/migrations/mysql/2026-07-31-120000_add_device_trusted_encryption/up.sql @@ -0,0 +1,3 @@ +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; diff --git a/migrations/mysql/2026-07-31-130000_add_auth_request_type/down.sql b/migrations/mysql/2026-07-31-130000_add_auth_request_type/down.sql new file mode 100644 index 00000000..3e6ab6ee --- /dev/null +++ b/migrations/mysql/2026-07-31-130000_add_auth_request_type/down.sql @@ -0,0 +1 @@ +ALTER TABLE auth_requests DROP COLUMN atype; diff --git a/migrations/mysql/2026-07-31-130000_add_auth_request_type/up.sql b/migrations/mysql/2026-07-31-130000_add_auth_request_type/up.sql new file mode 100644 index 00000000..90fba887 --- /dev/null +++ b/migrations/mysql/2026-07-31-130000_add_auth_request_type/up.sql @@ -0,0 +1 @@ +ALTER TABLE auth_requests ADD COLUMN atype INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/mysql/2026-08-01-120000_add_auth_request_indexes/down.sql b/migrations/mysql/2026-08-01-120000_add_auth_request_indexes/down.sql new file mode 100644 index 00000000..38258616 --- /dev/null +++ b/migrations/mysql/2026-08-01-120000_add_auth_request_indexes/down.sql @@ -0,0 +1,2 @@ +DROP INDEX auth_requests_organization_type ON auth_requests; +DROP INDEX auth_requests_creation_date ON auth_requests; diff --git a/migrations/mysql/2026-08-01-120000_add_auth_request_indexes/up.sql b/migrations/mysql/2026-08-01-120000_add_auth_request_indexes/up.sql new file mode 100644 index 00000000..a757bdef --- /dev/null +++ b/migrations/mysql/2026-08-01-120000_add_auth_request_indexes/up.sql @@ -0,0 +1,2 @@ +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_device_trusted_encryption/down.sql b/migrations/postgresql/2026-07-31-120000_add_device_trusted_encryption/down.sql new file mode 100644 index 00000000..7bd8c602 --- /dev/null +++ b/migrations/postgresql/2026-07-31-120000_add_device_trusted_encryption/down.sql @@ -0,0 +1,3 @@ +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_device_trusted_encryption/up.sql b/migrations/postgresql/2026-07-31-120000_add_device_trusted_encryption/up.sql new file mode 100644 index 00000000..33ef1168 --- /dev/null +++ b/migrations/postgresql/2026-07-31-120000_add_device_trusted_encryption/up.sql @@ -0,0 +1,3 @@ +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; diff --git a/migrations/postgresql/2026-07-31-130000_add_auth_request_type/down.sql b/migrations/postgresql/2026-07-31-130000_add_auth_request_type/down.sql new file mode 100644 index 00000000..3e6ab6ee --- /dev/null +++ b/migrations/postgresql/2026-07-31-130000_add_auth_request_type/down.sql @@ -0,0 +1 @@ +ALTER TABLE auth_requests DROP COLUMN atype; diff --git a/migrations/postgresql/2026-07-31-130000_add_auth_request_type/up.sql b/migrations/postgresql/2026-07-31-130000_add_auth_request_type/up.sql new file mode 100644 index 00000000..90fba887 --- /dev/null +++ b/migrations/postgresql/2026-07-31-130000_add_auth_request_type/up.sql @@ -0,0 +1 @@ +ALTER TABLE auth_requests ADD COLUMN atype INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/postgresql/2026-08-01-120000_add_auth_request_indexes/down.sql b/migrations/postgresql/2026-08-01-120000_add_auth_request_indexes/down.sql new file mode 100644 index 00000000..9f880bae --- /dev/null +++ b/migrations/postgresql/2026-08-01-120000_add_auth_request_indexes/down.sql @@ -0,0 +1,2 @@ +DROP INDEX auth_requests_organization_type; +DROP INDEX auth_requests_creation_date; diff --git a/migrations/postgresql/2026-08-01-120000_add_auth_request_indexes/up.sql b/migrations/postgresql/2026-08-01-120000_add_auth_request_indexes/up.sql new file mode 100644 index 00000000..a757bdef --- /dev/null +++ b/migrations/postgresql/2026-08-01-120000_add_auth_request_indexes/up.sql @@ -0,0 +1,2 @@ +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_device_trusted_encryption/down.sql b/migrations/sqlite/2026-07-31-120000_add_device_trusted_encryption/down.sql new file mode 100644 index 00000000..7bd8c602 --- /dev/null +++ b/migrations/sqlite/2026-07-31-120000_add_device_trusted_encryption/down.sql @@ -0,0 +1,3 @@ +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_device_trusted_encryption/up.sql b/migrations/sqlite/2026-07-31-120000_add_device_trusted_encryption/up.sql new file mode 100644 index 00000000..33ef1168 --- /dev/null +++ b/migrations/sqlite/2026-07-31-120000_add_device_trusted_encryption/up.sql @@ -0,0 +1,3 @@ +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; diff --git a/migrations/sqlite/2026-07-31-130000_add_auth_request_type/down.sql b/migrations/sqlite/2026-07-31-130000_add_auth_request_type/down.sql new file mode 100644 index 00000000..3e6ab6ee --- /dev/null +++ b/migrations/sqlite/2026-07-31-130000_add_auth_request_type/down.sql @@ -0,0 +1 @@ +ALTER TABLE auth_requests DROP COLUMN atype; diff --git a/migrations/sqlite/2026-07-31-130000_add_auth_request_type/up.sql b/migrations/sqlite/2026-07-31-130000_add_auth_request_type/up.sql new file mode 100644 index 00000000..90fba887 --- /dev/null +++ b/migrations/sqlite/2026-07-31-130000_add_auth_request_type/up.sql @@ -0,0 +1 @@ +ALTER TABLE auth_requests ADD COLUMN atype INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/2026-08-01-120000_add_auth_request_indexes/down.sql b/migrations/sqlite/2026-08-01-120000_add_auth_request_indexes/down.sql new file mode 100644 index 00000000..9f880bae --- /dev/null +++ b/migrations/sqlite/2026-08-01-120000_add_auth_request_indexes/down.sql @@ -0,0 +1,2 @@ +DROP INDEX auth_requests_organization_type; +DROP INDEX auth_requests_creation_date; diff --git a/migrations/sqlite/2026-08-01-120000_add_auth_request_indexes/up.sql b/migrations/sqlite/2026-08-01-120000_add_auth_request_indexes/up.sql new file mode 100644 index 00000000..a757bdef --- /dev/null +++ b/migrations/sqlite/2026-08-01-120000_add_auth_request_indexes/up.sql @@ -0,0 +1,2 @@ +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 626f22bb..32e3415e 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,9 +20,10 @@ use crate::{ db::{ DbConn, DbPool, models::{ - AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, - EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, 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, + Membership, MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, + UserId, UserKdfType, }, }, mail, @@ -44,6 +45,7 @@ pub fn routes() -> Vec { post_keys, post_password, post_set_password, + put_update_tde_offboarding_password, post_kdf, post_rotatekey, post_sstamp, @@ -68,8 +70,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, @@ -440,8 +449,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, @@ -449,6 +461,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( @@ -461,7 +486,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); } @@ -498,6 +523,88 @@ 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. +/// +/// This is the endpoint the clients take when a login answered `IsTdeOffboarding`, see +/// `trusted_device_option`. It is deliberately not `/accounts/set-password`: the account is fully +/// set up by this point, so the only thing being added is a second way to unlock the user key it +/// already has. The account key pair and the vault are left exactly as they are, and unlike +/// `/accounts/keys` there is nothing here that 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 password the first one just set. + 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) @@ -579,6 +686,25 @@ 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 retried request does not fail. Mirrors the guard in `post_set_password`. + 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); @@ -796,6 +922,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)] @@ -825,6 +965,57 @@ 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. Untrusting is the client's own separate step, and +/// the current ones take it before they get here. +/// 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`, taken by the device itself once it holds the + // device key that these two keys are wrapped for. An entry for anything else is passed over, + // as upstream does; the clients only ever send the devices we reported as trusted. + 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], @@ -929,6 +1120,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, @@ -940,6 +1132,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. @@ -1002,6 +1199,21 @@ 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: by this point the ciphers have already + // been rewritten under the new user key, so a device that holds the new one is the half that + // still works if what follows fails. The other order would leave a device counting itself + // trusted while handing its owner the key it just stopped needing. + 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; @@ -1568,6 +1780,216 @@ 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 that +/// holds 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, +} + +/// 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 and would no longer unlock anything. +/// +/// 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 the trust of a single device without +/// rotating the account. 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?; + + validate_enc_strings(&[ + ("encryptedUserKey", &data.current_device.encrypted_user_key), + ("encryptedPublicKey", &data.current_device.encrypted_public_key), + ])?; + + let devices = Device::find_by_user(&headers.user.uuid, &conn).await; + if !devices.iter().any(|device| device.uuid == headers.device.uuid) { + 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 updates = vec![( + headers.device.uuid.clone(), + data.current_device.encrypted_user_key, + data.current_device.encrypted_public_key, + )]; + let mut listed: HashSet = HashSet::from([headers.device.uuid.clone()]); + + // Validate everything before writing anything, so one bad entry cannot leave the devices + // wrapping a mix of the old and the new user key. + for other in data.other_devices { + if !listed.insert(other.device_id.clone()) { + if other.device_id == headers.device.uuid { + 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) = 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), + ])?; + + // A rotation clears the wrapped user key of every device, so the listed ones are not + // trusted at this point; their key pair is what they are restored from. Without it there is + // nothing the two keys could belong to, so the device is left to be untrusted instead. + if device.holds_private_key() { + updates.push((other.device_id, other.keys.encrypted_user_key, other.keys.encrypted_public_key)); + } + } + + Device::replace_trust(&headers.user.uuid, updates, &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. +/// +/// There is nothing left to clean up at this point, 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!({ @@ -1583,9 +2005,58 @@ 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 at all would break the page + /// listing the requests rather than just this one. + 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 = "")] @@ -1597,6 +2068,14 @@ async fn post_auth_request( ) -> JsonResult { 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") }; @@ -1607,8 +2086,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 mut auth_request = AuthRequest::new( user.uuid.clone(), + None, + atype, data.device_identifier.clone(), client_headers.device_type, client_headers.ip.ip.to_string(), @@ -1628,19 +2113,141 @@ 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 a limit of + // its own even though the caller is authenticated. + 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 means handing 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 does not + // need the email address, the address and the device of the asker. + 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, and the fingerprint an administrator reads out + // is derived from it. A client that asks again with a new key pair is therefore asking + // something else, and giving it the id of the pending request would let an administrator + // who is still looking at the old one approve it for a key the requester has 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 mut 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/")] @@ -1650,21 +2257,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)] @@ -1691,6 +2290,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") } @@ -1699,8 +2305,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); @@ -1711,7 +2335,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, @@ -1734,19 +2358,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?")] @@ -1767,21 +2379,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 @@ -1798,7 +2400,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)); @@ -1829,3 +2432,147 @@ 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 + } + + 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() + } + + #[test] + fn a_trusted_device_that_is_listed_keeps_its_trust() { + let devices = [device("a", true), device("b", true)]; + let updates = [update("a"), update("b")]; + + let result = validate_device_keydata(&updates, &devices).unwrap(); + assert_eq!( + rotated(&result), + ["a=4.bmV3dXNlcmtleQ==", "b=4.bmV3dXNlcmtleQ=="], + "both are re-wrapped, neither keeps the previous user key" + ); + } + + #[test] + fn a_trusted_device_that_is_left_out_takes_the_rotation_down_with_it() { + // Silently dropping the trust of a device the user still relies on is not the server's call + // to make; the client untrusts it first if that is what it means. + let devices = [device("a", true), device("b", true)]; + + let err = validate_device_keydata(&[update("a")], &devices).unwrap_err(); + assert!(format!("{err}").contains("All existing trusted devices must be included")); + } + + #[test] + fn a_device_of_somebody_else_is_refused() { + let devices = [device("a", true)]; + + let err = validate_device_keydata(&[update("a"), update("stranger")], &devices).unwrap_err(); + assert!(format!("{err}").contains("does not belong to this user")); + } + + #[test] + fn the_same_device_may_not_be_listed_twice() { + // Two entries for one device means one of the two keys is dropped without anyone noticing + // which, so neither is taken. + let devices = [device("a", true)]; + + let err = validate_device_keydata(&[update("a"), update("a")], &devices).unwrap_err(); + assert!(format!("{err}").contains("listed more than once")); + } + + #[test] + fn a_key_that_is_not_an_encrypted_string_is_refused() { + let devices = [device("a", true)]; + + let mut broken = update("a"); + broken.encrypted_user_key = String::from("not an enc string"); + let err = validate_device_keydata(&[broken], &devices).unwrap_err(); + assert!(format!("{err}").contains("encryptedUserKey")); + + let mut broken = update("a"); + broken.encrypted_public_key = String::new(); + let err = validate_device_keydata(&[broken], &devices).unwrap_err(); + assert!(format!("{err}").contains("encryptedPublicKey")); + } + + #[test] + fn a_device_without_its_own_key_pair_is_not_given_a_user_key() { + // Half a trust is worth nothing to the client and would only fail at the next unlock, so + // the device is dropped from the rotation and ends up untrusted instead. + let devices = [device("a", true), device("b", false)]; + + let result = validate_device_keydata(&[update("a"), update("b")], &devices).unwrap(); + assert_eq!(rotated(&result), ["a=4.bmV3dXNlcmtleQ=="]); + } + + #[test] + fn a_user_who_trusts_no_device_rotates_nothing() { + let devices = [device("a", false)]; + + let result = validate_device_keydata(&[], &devices).unwrap(); + assert!(result.is_empty(), "and the leftovers of `a` are cleared by the write that follows"); + } + + #[test] + fn a_partially_trusted_device_does_not_have_to_be_listed() { + // It cannot unlock anything as it stands, so leaving it out is not the loss of a trust. + let devices = [device("a", true), half_trusted("b")]; + + let result = validate_device_keydata(&[update("a")], &devices).unwrap(); + assert_eq!(rotated(&result), ["a=4.bmV3dXNlcmtleQ=="]); + } + + /// 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; + assert!(!device.is_trusted(), "not trusted"); + assert!(device.holds_private_key(), "but still holds its key pair"); + device + } + + #[test] + fn a_rotation_does_not_trust_a_device_that_was_not_trusted() { + // Trusting a device is `PUT /devices//keys`, taken by the device itself once it holds + // the device key these blobs are wrapped for. A rotation only carries an existing trust + // over to the new user key, so listing an untrusted device here gains it nothing, even + // though its key pair is still around for the trust it could be given later. + let devices = [device("a", true), half_trusted("b")]; + + let result = validate_device_keydata(&[update("a"), update("b")], &devices).unwrap(); + assert_eq!(rotated(&result), ["a=4.bmV3dXNlcmtleQ=="], "`b` is passed over and cleared by the write"); + } + + #[test] + fn a_rotation_cannot_hand_a_user_their_first_trusted_device() { + // The same the other way round: with nothing to carry over, a rotation writes no trust at + // all, however much the request offers. + let devices = [half_trusted("a"), half_trusted("b")]; + + let result = validate_device_keydata(&[update("a"), update("b")], &devices).unwrap(); + assert!(result.is_empty(), "no device was trusted before the rotation, so none is after it"); + } +} diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 9082297f..830f476f 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, serde::json::Json}; use serde_json::Value; @@ -8,16 +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, 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, User, UserId, }, }, mail, @@ -97,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, @@ -3153,7 +3162,14 @@ 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 on a + // server-wide setting as here. + // 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, @@ -3162,21 +3178,321 @@ 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 and no admin could ever confirm them. + // + // Tied to the same condition as the exception above, so that turning the feature off leaves the + // invitation flow exactly as it was: an invitation is otherwise accepted only against the token + // that was mailed out, and that is 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 a request nobody can answer +/// 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. Failing the batch instead would report an error while +/// having already answered everything before the bad entry. +#[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 leave it out of a notification meant for somebody else's account and hand its + // identifiers to the push relay under a foreign user id. + 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. @@ -3214,7 +3530,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 2b1ddfb1..e23a20c0 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,179 @@ 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 walks into +/// the same screen and 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, so offering the flow on the strength of one would walk the client into the half + // built account this whole function exists to avoid. + 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 decide it is looking at a fresh account and try to create + /// one. + 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. Upstream reads + // a `ManageResetPassword` permission here, which in Vaultwarden's role model only the + // administrators of an organization have. + // + // Every active membership counts, not only the confirmed one that may act on the permission + // today: an administrator provisioned into the organization by this very login holds the role + // before anybody has confirmed them, and this is the login that has to tell them to set a + // master password. 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 +684,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 +746,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 +771,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() { @@ -1350,3 +1529,194 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, Ok(Redirect::temporary(String::from(auth_url))) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::models::MembershipStatus; + + /// A `TrustedDeviceWaysIn` plus the server setting, so the cases below read as what they are. + #[expect(clippy::struct_excessive_bools, reason = "Mirrors the struct under test")] + struct Account { + enabled: bool, + device_is_trusted: bool, + has_master_password: bool, + has_admin_approval: bool, + can_create_account: bool, + } + + impl Account { + /// A user of a server that offers trusted devices, on a device it does not know yet, with + /// nothing set up: the shape everything below varies from. + fn new() -> Self { + Self { + enabled: true, + device_is_trusted: false, + has_master_password: false, + has_admin_approval: false, + can_create_account: false, + } + } + + fn offer(&self) -> Option { + TrustedDeviceWaysIn { + device_is_trusted: self.device_is_trusted, + has_master_password: self.has_master_password, + has_admin_approval: self.has_admin_approval, + can_create_account: self.can_create_account, + } + .offer(self.enabled) + } + } + + #[test] + fn a_server_that_does_not_offer_trusted_devices_says_nothing_about_them() { + for (device_is_trusted, has_master_password) in [(false, false), (false, true), (true, true)] { + let account = Account { + enabled: false, + device_is_trusted, + has_master_password, + ..Account::new() + }; + assert_eq!(account.offer(), None); + } + } + + #[test] + fn a_user_left_on_a_trusted_device_is_walked_off_the_feature() { + // The feature is gone but this device still unlocks and its owner has no master password. + // They are told so, so their client can walk them through setting one while they still can. + let account = Account { + enabled: false, + device_is_trusted: true, + ..Account::new() + }; + assert_eq!(account.offer(), Some(true), "offboarding"); + + // With the feature on, the same device is simply trusted. + let account = Account { + device_is_trusted: true, + ..Account::new() + }; + assert_eq!(account.offer(), Some(false)); + } + + #[test] + fn an_account_with_no_way_through_the_flow_is_not_offered_it() { + // Nothing set up, nobody to ask, and account creation would fail at the enrolment: the one + // combination that would leave the account half built. The client is sent to setting a + // master password instead. + assert_eq!(Account::new().offer(), None); + } + + #[test] + fn every_way_through_the_flow_is_offered_it() { + // A device that can unlock right now. + assert_eq!( + Account { + device_is_trusted: true, + ..Account::new() + } + .offer(), + Some(false) + ); + + // An administrator to ask, which needs the member to be enrolled in account recovery. + assert_eq!( + Account { + has_admin_approval: true, + ..Account::new() + } + .offer(), + Some(false) + ); + + // A master password to fall back on. + assert_eq!( + Account { + has_master_password: true, + ..Account::new() + } + .offer(), + Some(false) + ); + + // A fresh account in an organization that can actually take the enrolment. + assert_eq!( + Account { + can_create_account: true, + ..Account::new() + } + .offer(), + Some(false) + ); + } + + #[test] + fn a_user_with_a_master_password_is_never_offboarded() { + // There is nothing to walk them off, they can unlock either way. + let account = Account { + enabled: false, + device_is_trusted: true, + has_master_password: true, + ..Account::new() + }; + assert_eq!(account.offer(), None); + } + + /// What `trusted_device_option` reads off the memberships of the user logging in. + fn has_admin_approval(memberships: &[Membership]) -> bool { + memberships.iter().any(Membership::can_use_admin_approval) + } + + fn membership(org: &str, status: MembershipStatus, enrolled: bool) -> Membership { + let mut membership = Membership::new(String::from("user").into(), org.to_owned().into(), None); + membership.status = status as i32; + membership.reset_password_key = enrolled.then(|| String::from("2.aXY=|Y2lwaGVy|bWFj")); + membership + } + + #[test] + fn enrolling_into_trusted_devices_leaves_an_administrator_to_ask() { + // Invited into an organization that unlocks with trusted devices, before enrolling: nobody + // holds a key to approve with yet. + let mut memberships = [membership("org", MembershipStatus::Invited, false)]; + assert!(!has_admin_approval(&memberships)); + + // Enrolling is what `put_reset_password_enrollment` does for an account without a master + // password: it writes the key and accepts the invitation in the same step. Confirming the + // member is an administrator's own, later decision, and until they get round to it the + // member is stuck here. + memberships[0].status = MembershipStatus::Accepted as i32; + memberships[0].reset_password_key = Some(String::from("2.aXY=|Y2lwaGVy|bWFj")); + + assert!(has_admin_approval(&memberships), "the enrolment is what an administrator answers with"); + + // Losing the trusted device at that point is the case this covers: no master password, no + // device that unlocks, and an administrator to ask is the only way back in. + let account = Account { + has_admin_approval: has_admin_approval(&memberships), + ..Account::new() + }; + assert_eq!(account.offer(), Some(false), "the flow leads somewhere, so it is offered"); + } + + #[test] + fn one_organization_that_could_approve_is_enough() { + // A member of several organizations only needs one of them to hold a key for them. + let memberships = [ + membership("invited", MembershipStatus::Invited, true), + membership("not-enrolled", MembershipStatus::Confirmed, false), + membership("enrolled", MembershipStatus::Accepted, true), + ]; + assert!(has_admin_approval(&memberships)); + + // Take that one away and there is nobody left to ask, however many organizations remain. + let memberships = [ + membership("invited", MembershipStatus::Invited, true), + membership("not-enrolled", MembershipStatus::Confirmed, false), + membership("revoked", MembershipStatus::Revoked, true), + ]; + assert!(!has_admin_approval(&memberships)); + } +} 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..87c52ede 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -317,13 +317,23 @@ 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 is the one device left out of the notification, +/// since it already knows. An answer that did not come from a device of this user at all, as an +/// approval by an administrator of their organization does, leaves it out: naming a device of +/// somebody else here would both hand its identifiers to the push relay under a foreign 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 762088e5..39a030bd 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -843,6 +843,41 @@ 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 of an organization and to nobody else, which +/// makes this the same set of callers as `AdminHeaders`; keeping it apart is what lets a custom role +/// hold the permission later without every endpoint having to be revisited. +/// 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 87bea195..93747fa8 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 { @@ -1749,6 +1757,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"); @@ -1770,6 +1779,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 a3876661..d1a1a993 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,54 @@ 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 that is waiting for them, which is 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 + /// request that is still waiting has none, and handing one out here would be crypto material + /// the answering side has no use for. + 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 { @@ -138,16 +224,27 @@ impl AuthRequest { .await } + /// The request a device is currently waiting on, if it is still open and still 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() @@ -155,16 +252,69 @@ impl AuthRequest { .await } - pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { + /// 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 client + /// that retries cannot fill the table or mail the administrators over and over. + /// + /// A request past its window does not count: it is one nobody can answer 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 after it ran out is a new request, and is announced. + 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::creation_date.lt(dt)) + .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() + }) + .await + } + + /// 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::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))) @@ -178,12 +328,56 @@ impl AuthRequest { ct_eq(&self.access_code, access_code) } + /// Drops everything past its window, which is a different one per type. + /// + /// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql + /// One statement per case rather than reading the table and deleting row by row, so the work + /// stays in the database however many requests have piled up. 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:#?}"); } } } @@ -205,3 +399,88 @@ 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 a_request_between_the_users_own_devices_is_short_lived() { + 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()); + } + + #[test] + fn an_administrator_gets_a_week_to_answer() { + assert!(!request(AuthRequestType::AdminApproval, TimeDelta::try_days(6).unwrap()).is_expired()); + assert!(request(AuthRequestType::AdminApproval, TimeDelta::try_days(8).unwrap()).is_expired()); + } + + #[test] + fn a_request_nobody_answered_in_time_is_not_still_pending() { + // `find_pending_admin_approval` decides whether asking again reuses the open request or + // starts a new one, and filters on the same window as this. A request past it must not come + // back: reviving it by moving its date forward would leave the user waiting on something + // the administrators were never told about, because only a new request mails them. + let mut auth_request = + request(AuthRequestType::AdminApproval, AuthRequest::admin_request_expiration() + TimeDelta::seconds(1)); + assert_eq!(auth_request.approved, None, "still unanswered"); + assert!(auth_request.is_expired()); + + // One minute short of the window is still the same request, and asking again updates it + // rather than mailing everyone a second time. + auth_request.creation_date = + Utc::now().naive_utc() - AuthRequest::admin_request_expiration() + TimeDelta::minutes(1); + assert!(!auth_request.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()); + } + + #[test] + fn unknown_request_types_are_rejected() { + 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 6c1b686a..2ecb14d2 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,47 @@ 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 rotation of the user key does not touch, so + /// it outlives one. It is what decides whether a device can be handed a freshly wrapped user + /// key and be trusted again, or whether it has to be set up from scratch. + 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 +123,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 +195,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", }) } @@ -177,6 +249,106 @@ 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 itself is replaced and the client did not say what to put in their + /// place, which leaves all of those copies pointing at a key that no longer unlocks anything. No + /// device counts as trusted afterwards, so a client that stops here ends up with an extra login + /// rather than a broken unlock. The device key pairs are deliberately left alone: they are + /// wrapped with the device key, which a rotation does not touch, so `POST /devices/update-trust` + /// can hand every device the new user key and restore its trust. Whatever it does not list is + /// dropped there. + /// + /// One statement, so there is no half applied state to reason about. + 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 both a key rotation and `POST /devices/update-trust` come down to. The caller + /// has already checked that every id belongs to this user, that none is listed twice, and that + /// no device is asked to keep a trust it cannot complete; this only writes. + /// + /// One transaction, so the devices cannot be left split between the old and the new user key, + /// which is a state no client can tell apart from a working one until an unlock fails. + 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 + } + pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option { conn.run(move |conn| { devices::table @@ -364,6 +536,18 @@ 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, which allows the + /// desktop, mobile, web and browser client types. + pub fn can_approve_login_requests(&self) -> bool { + !matches!( + self, + DeviceType::Sdk | DeviceType::Server | DeviceType::WindowsCLI | DeviceType::MacOsCLI | DeviceType::LinuxCLI + ) + } } #[derive( @@ -373,3 +557,81 @@ 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 + } + + #[test] + fn a_device_is_only_trusted_with_all_three_keys() { + assert!(trusted_device().is_trusted()); + + 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 { + let mut device = trusted_device(); + *key(&mut device) = None; + assert!(!device.is_trusted()); + + let mut device = trusted_device(); + *key(&mut device) = Some(String::new()); + assert!(!device.is_trusted(), "an empty key is as good as a missing one"); + } + } + + #[test] + fn an_incomplete_device_hands_out_no_keys_at_all() { + let mut device = trusted_device(); + assert_eq!(device.trusted_user_key(), Some(&String::from("2.user"))); + assert_eq!(device.trusted_private_key(), Some(&String::from("2.private"))); + + // The public key is not part of the login response, but without it the other two are + // useless to the client, so it must not get them either. + device.encrypted_public_key = None; + 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.is_trusted(), "nothing may unlock until the client re-wraps"); + assert!(device.holds_private_key(), "but the device can still be handed a new user key"); + } + + #[test] + fn a_device_that_never_had_a_trust_holds_nothing() { + let device = Device::new(String::from("device").into(), String::from("user").into(), String::new(), 9); + assert!(!device.holds_private_key()); + + let mut device = trusted_device(); + device.encrypted_private_key = Some(String::new()); + assert!(!device.holds_private_key(), "an empty key is as good as a missing one"); + } + + #[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 86cbf5d0..58befaf4 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 0ed8ef91..148a2105 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 bdb69864..4980362b 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -277,6 +277,77 @@ impl Membership { } } + /// Whether this membership is in one of the active states rather than a revoked one. + /// + /// Revoking does not write `Revoked`, it shifts the status the membership is to be restored to + /// out of the active range, so a revoked row reads `-128`, `-127` or `-126` and never `-1`. + /// `MembershipStatus::from_i32` only knows the three active values, which is exactly how + /// `OrgHeaders` turns a revoked member away, so it is what decides it here too. Comparing + /// against `Revoked` instead would let every one of those stored 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 is a permission of its own, `ManageResetPassword`, which an administrator has by + /// virtue of the role and a custom role can be granted separately. Vaultwarden folds the custom + /// role into `Manager` and drops the permissions that came with it, so only the administrators + /// are left holding it. Asking here rather than comparing roles at each call site keeps that one + /// 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 membership that is fully established. + /// An invitation that was never accepted and one that is still waiting to be confirmed are 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. + /// + /// A weaker question than `can_manage_reset_password_now`, and deliberately so: 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 a member who was just provisioned into + /// the organization by their first SSO login holds the role before anyone confirms them, and + /// waiting until then would let an administrator through the trusted device flow without ever + /// being asked for the master password their own role requires of them. + /// + /// A revoked membership is not active and never counts, here or anywhere else. + /// 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 means handing the member their own user key, wrapped for the asking device. The + /// only copy of it the organization has is the one enrolling into account recovery left behind, + /// so without that key there is nothing to approve with, whatever the member's standing is. + /// + /// Enrolling is also what turns an invitation into a membership in the trusted device flow, so + /// the state this has to cover is `Accepted` and not just `Confirmed`: a member who set up + /// trusted devices and then lost the device before an administrator got round to confirming + /// them would otherwise have no way back into their own vault. Upstream asks for the enrollment + /// alone and lets any membership row through; the two ends of the range are kept out here + /// because an invitation is not a membership yet and a revoked one is not one anymore, so + /// neither should have its device let in. + /// 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; @@ -1285,4 +1356,168 @@ mod tests { assert!(MembershipType::Manager > MembershipType::User); assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); } + + #[test] + fn only_a_confirmed_administrator_manages_account_recovery() { + let mut membership = Membership::new(String::from("user").into(), String::from("org").into(), None); + + for (atype, expected) 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::Revoked, MembershipStatus::Invited, MembershipStatus::Accepted] { + let status = status as i32; + membership.status = status; + assert!( + !membership.can_manage_reset_password_now(), + "a membership that is not confirmed manages nothing, status {status}" + ); + } + + membership.status = MembershipStatus::Confirmed as i32; + assert_eq!(membership.can_manage_reset_password_now(), expected, "type {}", atype as i32); + } + } + + #[test] + fn the_trusted_device_role_signal_covers_a_member_nobody_confirmed_yet() { + 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), + (MembershipType::Manager, false), + (MembershipType::User, false), + ] { + membership.atype = atype as i32; + + // Every active membership answers the same, so an administrator who was provisioned by + // the login that is asking is told to set a master password straight away. + for status in [MembershipStatus::Invited, MembershipStatus::Accepted, MembershipStatus::Confirmed] { + let status = status as i32; + membership.status = status; + assert_eq!( + membership.has_manage_reset_password_role_for_tde(), + holds_role, + "type {}, status {status}", + atype as i32 + ); + } + + // Revoked never counts, whatever the role says. + membership.status = MembershipStatus::Revoked as i32; + assert!(!membership.has_manage_reset_password_role_for_tde(), "revoked, type {}", atype as i32); + + // Nor do the internal statuses a revoked membership is actually stored as, which keep + // the role it is to be restored to. + for was in [MembershipStatus::Invited, MembershipStatus::Accepted, MembershipStatus::Confirmed] { + let was = was as i32; + membership.status = was; + assert!(membership.revoke(), "revoking a {was} membership"); + assert!( + !membership.has_manage_reset_password_role_for_tde(), + "revoked from {was}, stored as {}", + membership.status + ); + } + } + } + + #[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 is + // what would let these through. + assert!(membership.revoke(), "revoking status {status}"); + assert_ne!( + membership.status, + MembershipStatus::Revoked as i32, + "revoked from {status} is not stored as -1" + ); + assert!(!membership.is_active(), "revoked from {status}, stored as {}", membership.status); + + assert!(membership.restore(), "restoring status {status}"); + assert_eq!(membership.status, status, "restored to what it was"); + assert!(membership.is_active()); + } + + // 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 only_a_confirmed_membership_may_act_on_account_recovery() { + // The two questions are deliberately not the same one: being told to set a master password + // is not being allowed to reset somebody else's. + let mut membership = Membership::new(String::from("user").into(), String::from("org").into(), None); + membership.atype = MembershipType::Admin as i32; + + for status in [MembershipStatus::Invited, MembershipStatus::Accepted] { + let status = status as i32; + membership.status = status; + assert!(membership.has_manage_reset_password_role_for_tde(), "the login signal covers status {status}"); + assert!(!membership.can_manage_reset_password_now(), "but the endpoints do not, status {status}"); + } + + membership.status = MembershipStatus::Confirmed as i32; + assert!(membership.has_manage_reset_password_role_for_tde()); + assert!(membership.can_manage_reset_password_now()); + } + + #[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"), + // Revoking takes the access away but leaves the key behind, which must not keep letting + // new devices in. + (MembershipStatus::Revoked, true, false, "a revoked membership is not one anymore"), + ] { + 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 a member who was enrolled keeps their key, and stores a status that is not + // `Revoked` itself. None of those may keep letting new devices in. + 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.can_use_admin_approval(), "enrolled and active, 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 af342186..66687ce6 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, } } @@ -328,6 +331,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 a7e5e5ae..d3de1ef1 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..a7a015da 100644 --- a/src/util.rs +++ b/src/util.rs @@ -540,6 +540,168 @@ 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 that is supposed to hold a wrapped key. The largest thing that legitimately +/// lands there 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` Rsa2048_OaepSha256_B64 and `4` Rsa2048_OaepSha1_B64 are the ciphertext by itself, and +/// `7` XChaCha20Poly1305_B64 is one blob of COSE bytes; +/// - `0` AesCbc256_B64 is `iv|ct`, while `5` Rsa2048_OaepSha256_HmacSha256_B64 and +/// `6` Rsa2048_OaepSha1_HmacSha256_B64 are `rsaCt|mac`; +/// - `1` AesCbc128_HmacSha256_B64 and `2` AesCbc256_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 + ] { + assert!(is_valid_enc_string(value), "{value}"); + } + } + + #[test] + fn a_non_canonical_final_character_is_accepted() { + // The unused padding bits of the last character are not zero. A strict decoder refuses + // these, upstream deliberately does not, and such values exist in the wild. + assert!(is_valid_enc_string("3.QR==")); + assert!(is_valid_enc_string("3.QUJDRR==")); + assert!(is_valid_enc_string("2.aXY=|QR==|bWFj")); + } + + #[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 //