diff --git a/.env.template b/.env.template index fd7c2fd2..375a99e5 100644 --- a/.env.template +++ b/.env.template @@ -549,6 +549,14 @@ ## 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 either by approving it from an already trusted one or with the master password. +## WARNING: a user who never sets a master password and then loses every trusted device cannot +## recover their vault. This server does not implement the admin approval flow to fall back on. +# SSO_TRUSTED_DEVICE_ENCRYPTION=false + ######################## ### MFA/2FA settings ### ######################## 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/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/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/src/api/core/accounts.rs b/src/api/core/accounts.rs index 0cb4d3c0..81a0ceae 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::{ @@ -68,6 +68,12 @@ 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, get_auth_request, @@ -440,8 +446,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 +458,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 +483,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); } @@ -579,6 +601,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); @@ -994,6 +1035,12 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: let save_result = user.save(&conn).await; + // Every trusted device holds the previous user key wrapped for itself, which unlocks nothing + // anymore. The client of the rotating device re-wraps the new one right after this via + // `POST /devices/update-trust`, so that device keeps its private key; the rest is dropped. A + // client that skips that call simply ends up with no trusted device instead of a broken unlock. + Device::invalidate_wrapped_user_keys(&user.uuid, &headers.device.uuid, &conn).await?; + // Prevent logging out the client where the user requested this endpoint from. // If you do logout the user it will causes issues at the client side. // Adding the device uuid will prevent this. @@ -1545,6 +1592,205 @@ 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, +} + +/// 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(); + + if data.encrypted_user_key.is_empty() + || data.encrypted_public_key.is_empty() + || data.encrypted_private_key.is_empty() + { + err!("All three device keys are required to trust a device") + } + + 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. +#[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?; + + if data.current_device.encrypted_user_key.is_empty() || data.current_device.encrypted_public_key.is_empty() { + err!("The keys of the current device are required") + } + + let mut updates: HashMap = HashMap::new(); + for other in data.other_devices { + if other.device_id == headers.device.uuid { + err!("The current device cannot also be part of the optional rotation") + } + if other.keys.encrypted_user_key.is_empty() || other.keys.encrypted_public_key.is_empty() { + err!("Both keys are required for every device in the rotation") + } + if updates.insert(other.device_id, other.keys).is_some() { + err!("A device was listed more than once in the rotation") + } + } + + 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") + } + + // Validate everything before writing anything: a rotation that stops halfway would leave the + // devices wrapping a mix of the old and the new user key. + if let Some(unknown) = updates.keys().find(|device_id| !devices.iter().any(|device| device.uuid == **device_id)) { + err!(format!("Device {unknown} does not belong to this user")) + } + + for mut device in devices { + if device.uuid == headers.device.uuid { + device.encrypted_user_key = Some(data.current_device.encrypted_user_key.clone()); + device.encrypted_public_key = Some(data.current_device.encrypted_public_key.clone()); + } else if !device.is_trusted() { + // Nothing to rotate, and handing an untrusted device two of the three keys would not + // make it trusted anyway. + continue; + } else if let Some(keys) = updates.remove(&device.uuid) { + device.encrypted_user_key = Some(keys.encrypted_user_key); + device.encrypted_public_key = Some(keys.encrypted_public_key); + } else { + device.untrust(); + } + + device.save(true, &conn).await?; + } + + Ok(()) +} + +#[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 mut devices = Device::find_by_user(&headers.user.uuid, &conn).await; + + // 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| !devices.iter().any(|device| &device.uuid == *device_id)) + { + err!(format!("Device {unknown} does not belong to this user")) + } + + for device in devices.iter_mut().filter(|device| data.devices.contains(&device.uuid)) { + device.untrust(); + device.save(true, &conn).await?; + } + + Ok(()) +} + +/// 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!({ 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 9212ed8d..a25c83b3 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -30,7 +30,7 @@ use crate::{ db::{ DbConn, models::{ - AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, + AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, OIDCCodeResponseError, OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, }, @@ -356,7 +356,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( @@ -478,7 +478,46 @@ 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 +} + +/// 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(); + + // 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 && device.is_trusted() && user.password_hash.is_empty(); + if !enabled && !offboarding { + return None; + } + + // 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()); + + // Approval by an organization admin is not implemented. Announcing it would leave the client + // waiting on a request that nobody here can answer. + Some(json!({ + "HasAdminApproval": false, + "HasLoginApprovingDevice": has_login_approving_device, + "HasManageResetPasswordPermission": false, + "IsTdeOffboarding": offboarding, + "EncryptedPrivateKey": device.trusted_private_key(), + "EncryptedUserKey": device.trusted_user_key(), + "Object": "trustedDeviceUserDecryptionOption" + })) } async fn authenticated_response( @@ -486,6 +525,7 @@ async fn authenticated_response( device: &mut Device, auth_tokens: auth::AuthTokens, twofactor_token: Option, + sso_login: bool, conn: &DbConn, ip: &ClientIp, ) -> JsonResult { @@ -547,6 +587,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(), @@ -562,11 +612,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() { diff --git a/src/config.rs b/src/config.rs index c4457478..72007f3e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -840,6 +840,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 @@ -1107,6 +1109,8 @@ 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") } if cfg._enable_yubico { diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 6c1b686a..3d7391ec 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,44 @@ 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() + } + + pub fn untrust(&mut self) { + self.encrypted_user_key = None; + self.encrypted_public_key = None; + self.encrypted_private_key = None; + } + pub fn to_json(&self) -> Value { json!({ "id": self.uuid, @@ -68,11 +120,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 +192,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 +246,37 @@ 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, which leaves all of those copies pointing at a + /// key that no longer unlocks anything. `keep_private_key_for` (the device that performed the + /// rotation) keeps its own private key, so its client can immediately re-wrap the new user key + /// via `POST /devices/update-trust`; every other device is untrusted outright. Until that + /// happens no device counts as trusted, so the worst case is an extra login, not a broken vault. + pub async fn invalidate_wrapped_user_keys( + user_uuid: &UserId, + keep_private_key_for: &DeviceId, + conn: &DbConn, + ) -> EmptyResult { + conn.run(move |conn| { + let _: () = diesel::update( + devices::table.filter(devices::user_uuid.eq(user_uuid)).filter(devices::uuid.ne(keep_private_key_for)), + ) + .set(devices::encrypted_private_key.eq::>(None)) + .execute(conn) + .map_res("Error untrusting the devices")?; + + 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 + } + pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option { conn.run(move |conn| { devices::table @@ -364,6 +464,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 +485,70 @@ 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 untrusting_clears_every_key() { + let mut device = trusted_device(); + device.untrust(); + + assert!(!device.is_trusted()); + assert_eq!(device.encrypted_user_key, None); + assert_eq!(device.encrypted_public_key, None); + assert_eq!(device.encrypted_private_key, None); + } + + #[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/schema.rs b/src/db/schema.rs index af342186..b1766270 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, } }