Browse Source

Merge d7ee89d495 into 0cefa4cca7

pull/7534/merge
Tom 6 days ago
committed by GitHub
parent
commit
a46c01ff46
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 15
      .env.template
  2. 3
      migrations/mysql/2026-07-31-120000_add_device_trusted_encryption/down.sql
  3. 3
      migrations/mysql/2026-07-31-120000_add_device_trusted_encryption/up.sql
  4. 1
      migrations/mysql/2026-07-31-130000_add_auth_request_type/down.sql
  5. 1
      migrations/mysql/2026-07-31-130000_add_auth_request_type/up.sql
  6. 2
      migrations/mysql/2026-08-01-120000_add_auth_request_indexes/down.sql
  7. 2
      migrations/mysql/2026-08-01-120000_add_auth_request_indexes/up.sql
  8. 3
      migrations/postgresql/2026-07-31-120000_add_device_trusted_encryption/down.sql
  9. 3
      migrations/postgresql/2026-07-31-120000_add_device_trusted_encryption/up.sql
  10. 1
      migrations/postgresql/2026-07-31-130000_add_auth_request_type/down.sql
  11. 1
      migrations/postgresql/2026-07-31-130000_add_auth_request_type/up.sql
  12. 2
      migrations/postgresql/2026-08-01-120000_add_auth_request_indexes/down.sql
  13. 2
      migrations/postgresql/2026-08-01-120000_add_auth_request_indexes/up.sql
  14. 3
      migrations/sqlite/2026-07-31-120000_add_device_trusted_encryption/down.sql
  15. 3
      migrations/sqlite/2026-07-31-120000_add_device_trusted_encryption/up.sql
  16. 1
      migrations/sqlite/2026-07-31-130000_add_auth_request_type/down.sql
  17. 1
      migrations/sqlite/2026-07-31-130000_add_auth_request_type/up.sql
  18. 2
      migrations/sqlite/2026-08-01-120000_add_auth_request_indexes/down.sql
  19. 2
      migrations/sqlite/2026-08-01-120000_add_auth_request_indexes/up.sql
  20. 535
      src/api/core/accounts.rs
  21. 329
      src/api/core/organizations.rs
  22. 3
      src/api/core/sends.rs
  23. 119
      src/api/identity.rs
  24. 10
      src/config.rs
  25. 270
      src/db/models/auth_request.rs
  26. 220
      src/db/models/device.rs
  27. 2
      src/db/models/mod.rs
  28. 4
      src/db/schema.rs
  29. 51
      src/mail.rs
  30. 6
      src/static/templates/email/device_approval_requested.hbs
  31. 16
      src/static/templates/email/device_approval_requested.html.hbs
  32. 9
      src/static/templates/email/trusted_device_admin_approval.hbs
  33. 22
      src/static/templates/email/trusted_device_admin_approval.html.hbs
  34. 89
      src/util.rs

15
.env.template

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

3
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;

3
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;

1
migrations/mysql/2026-07-31-130000_add_auth_request_type/down.sql

@ -0,0 +1 @@
ALTER TABLE auth_requests DROP COLUMN atype;

1
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;

2
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;

2
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);

3
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;

3
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;

1
migrations/postgresql/2026-07-31-130000_add_auth_request_type/down.sql

@ -0,0 +1 @@
ALTER TABLE auth_requests DROP COLUMN atype;

1
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;

2
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;

2
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);

3
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;

3
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;

1
migrations/sqlite/2026-07-31-130000_add_auth_request_type/down.sql

@ -0,0 +1 @@
ALTER TABLE auth_requests DROP COLUMN atype;

1
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;

2
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;

2
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);

535
src/api/core/accounts.rs

@ -1,4 +1,4 @@
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use chrono::Utc; use chrono::Utc;
use rocket::{ use rocket::{
@ -20,9 +20,10 @@ use crate::{
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{ models::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, AuthRequest, AuthRequestId, AuthRequestType, Cipher, CipherId, Device, DeviceId, DeviceType,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, DeviceWithAuthRequest, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation,
OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
OrganizationId, Send, SendId, User, UserId, UserKdfType,
}, },
}, },
mail, mail,
@ -68,8 +69,15 @@ pub fn routes() -> Vec<rocket::Route> {
put_device_token, put_device_token,
put_clear_device_token, put_clear_device_token,
post_clear_device_token, post_clear_device_token,
put_device_keys,
post_device_keys,
post_device_retrieve_keys,
post_devices_update_trust,
post_devices_untrust,
post_devices_lost_trust,
get_tasks, get_tasks,
post_auth_request, post_auth_request,
post_admin_auth_request,
get_auth_request, get_auth_request,
put_auth_request, put_auth_request,
get_auth_request_response, get_auth_request_response,
@ -440,8 +448,11 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
let data: SetPasswordData = data.into_inner(); let data: SetPasswordData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
if user.private_key.is_some() { // A trusted device account already has its key pair but no master password, and must still be
err!("Account already initialized, cannot set password") // able to add one later, for instance once the server stops offering trusted device encryption.
// What this must never do is hand out a fresh master password for an account that has one.
if !user.password_hash.is_empty() {
err!("Account already has a master password")
} }
// Check against the password hint setting here so if it fails, // Check against the password hint setting here so if it fails,
@ -449,6 +460,19 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
let password_hint = clean_password_hint(data.master_password_hint.as_ref()); let password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(password_hint.as_ref())?; enforce_password_hint_setting(password_hint.as_ref())?;
// Same reasoning as in `post_keys`: the existing ciphers are encrypted under the existing key
// pair, so an account that has one only gets a password, never new keys.
let keys = match (data.keys, user.private_key.is_some() || user.public_key.is_some()) {
(Some(keys), false) => Some(keys),
(Some(keys), true)
if user.private_key.as_ref() != Some(&keys.encrypted_private_key)
|| user.public_key.as_ref() != Some(&keys.public_key) =>
{
err!("Account already initialized, cannot replace the account keys")
}
_ => None,
};
set_kdf_data(&mut user, &data.kdf)?; set_kdf_data(&mut user, &data.kdf)?;
user.set_password( user.set_password(
@ -461,7 +485,7 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
.await?; .await?;
user.password_hint = password_hint; user.password_hint = password_hint;
if let Some(keys) = data.keys { if let Some(keys) = keys {
user.private_key = Some(keys.encrypted_private_key); user.private_key = Some(keys.encrypted_private_key);
user.public_key = Some(keys.public_key); user.public_key = Some(keys.public_key);
} }
@ -579,6 +603,25 @@ async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> Json
let mut user = headers.user; let mut user = headers.user;
// Replacing the key pair of an initialized account would make every existing cipher
// undecryptable, so only accept it while the account has none yet. The clients call this during
// account creation, including the trusted device flow, where a stale client state could
// otherwise send us here for an account that is already set up. Repeating the same keys stays
// allowed so a 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.private_key = Some(data.encrypted_private_key);
user.public_key = Some(data.public_key); user.public_key = Some(data.public_key);
@ -979,6 +1022,14 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
} }
} }
// Every device holds the previous user key wrapped for itself, which unlocks nothing anymore.
// Drop those copies before the new key is written, never after: the other order leaves a window
// in which a device still counts as trusted and hands its owner a key that no longer opens the
// vault. This way a failure here means the rotation simply did not happen.
// The clients re-wrap the new user key for every device right after this via
// `POST /devices/update-trust`; whatever they leave out stays untrusted.
Device::invalidate_wrapped_user_keys(&headers.user.uuid, &conn).await?;
// Update user data // Update user data
let mut user = headers.user; let mut user = headers.user;
@ -1545,6 +1596,225 @@ async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn
put_clear_device_token(device_id, ip, conn).await put_clear_device_token(device_id, ip, conn).await
} }
// Trusted device encryption, see https://bitwarden.com/help/login-with-sso-trusted-devices/
// The three key blobs below are generated and encrypted by the client, the server only stores them
// and hands them back on the next login of that same device. It never learns the device key that
// unwraps `encrypted_private_key`, so a stored trust is worth nothing without the device itself.
// https://github.com/bitwarden/server/blob/main/src/Api/Controllers/DevicesController.cs
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TrustedDeviceKeysData {
encrypted_user_key: String,
encrypted_public_key: String,
encrypted_private_key: String,
}
/// Refuses anything that does not even have the shape of an `EncString`.
///
/// The server cannot tell whether a blob decrypts, but storing something that certainly does not
/// only leaves a device that calls itself trusted and fails its owner at the next unlock. Upstream
/// puts `[EncryptedString]` on the same fields.
fn validate_enc_strings(values: &[(&str, &str)]) -> EmptyResult {
for (name, value) in values {
if !crate::util::is_valid_enc_string(value) {
err!(format!("{name} is not a valid encrypted string"))
}
}
Ok(())
}
/// Marks a device of the current user as trusted.
///
/// Upstream keys this on the device identifier and does not require it to be the device the request
/// was authenticated with, so neither do we. The keys only ever unlock the vault on the device that
/// holds the matching device key, so writing them for another of your own devices gains nothing.
#[put("/devices/<device_id>/keys", data = "<data>")]
async fn put_device_keys(
device_id: DeviceId,
data: Json<TrustedDeviceKeysData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
let data = data.into_inner();
validate_enc_strings(&[
("encryptedUserKey", &data.encrypted_user_key),
("encryptedPublicKey", &data.encrypted_public_key),
("encryptedPrivateKey", &data.encrypted_private_key),
])?;
let Some(mut device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await else {
err!("No device found")
};
device.encrypted_user_key = Some(data.encrypted_user_key);
device.encrypted_public_key = Some(data.encrypted_public_key);
device.encrypted_private_key = Some(data.encrypted_private_key);
device.save(true, &conn).await?;
Ok(Json(device.to_json()))
}
// Deprecated upstream in favour of the PUT variant, but still served for older clients
#[post("/devices/<device_id>/keys", data = "<data>")]
async fn post_device_keys(
device_id: DeviceId,
data: Json<TrustedDeviceKeysData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
put_device_keys(device_id, data, headers, conn).await
}
/// The public half of a device's trust, needed by the clients to re-wrap the user key for every
/// trusted device during a key rotation.
#[post("/devices/<device_id>/retrieve-keys")]
async fn post_device_retrieve_keys(device_id: DeviceId, headers: Headers, conn: DbConn) -> JsonResult {
let Some(device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await else {
err!("No device found")
};
Ok(Json(device.to_protected_json()))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeviceTrustUpdateData {
encrypted_user_key: String,
encrypted_public_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OtherDeviceTrustUpdateData {
device_id: DeviceId,
#[serde(flatten)]
keys: DeviceTrustUpdateData,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateDevicesTrustData {
#[serde(flatten)]
secret: PasswordOrOtpData,
current_device: DeviceTrustUpdateData,
#[serde(default)]
other_devices: Vec<OtherDeviceTrustUpdateData>,
}
/// 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 = "<data>")]
async fn post_devices_update_trust(data: Json<UpdateDevicesTrustData>, headers: Headers, conn: DbConn) -> EmptyResult {
let data = data.into_inner();
data.secret.validate(&headers.user, true, &conn).await?;
validate_enc_strings(&[
("encryptedUserKey", &data.current_device.encrypted_user_key),
("encryptedPublicKey", &data.current_device.encrypted_public_key),
])?;
let mut updates: HashMap<DeviceId, DeviceTrustUpdateData> = 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")
}
validate_enc_strings(&[
("encryptedUserKey", &other.keys.encrypted_user_key),
("encryptedPublicKey", &other.keys.encrypted_public_key),
])?;
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 let Some(keys) = updates.remove(&device.uuid) {
// 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.
if !device.holds_private_key() {
continue;
}
device.encrypted_user_key = Some(keys.encrypted_user_key);
device.encrypted_public_key = Some(keys.encrypted_public_key);
} else if device.holds_any_key() {
// Not listed, so whatever it still holds wraps the previous user key.
device.untrust();
} else {
continue;
}
device.save(true, &conn).await?;
}
Ok(())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UntrustDevicesData {
devices: Vec<DeviceId>,
}
#[post("/devices/untrust", data = "<data>")]
async fn post_devices_untrust(data: Json<UntrustDevicesData>, headers: Headers, conn: DbConn) -> EmptyResult {
let data = data.into_inner();
let 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")] #[get("/tasks")]
fn get_tasks(_client_headers: ClientHeaders) -> JsonResult { fn get_tasks(_client_headers: ClientHeaders) -> JsonResult {
Ok(Json(json!({ Ok(Json(json!({
@ -1560,9 +1830,26 @@ struct AuthRequestRequest {
device_identifier: DeviceId, device_identifier: DeviceId,
email: String, email: String,
public_key: String, public_key: String,
// Not used for now #[serde(default, rename = "type")]
// #[serde(alias = "type")] atype: i32,
// _type: i32, }
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(),
"requestDeviceIdentifier": auth_request.request_device_identifier,
"requestIpAddress": auth_request.request_ip,
"key": auth_request.enc_key,
"masterPasswordHash": auth_request.master_password_hash,
"creationDate": format_date(&auth_request.creation_date),
"responseDate": auth_request.response_date.as_ref().map(format_date),
"requestApproved": auth_request.approved.unwrap_or(false),
"origin": CONFIG.domain_origin(),
"object": "auth-request"
})
} }
#[post("/auth-requests", data = "<data>")] #[post("/auth-requests", data = "<data>")]
@ -1574,6 +1861,12 @@ async fn post_auth_request(
) -> JsonResult { ) -> JsonResult {
let data = data.into_inner(); let data = data.into_inner();
// Asking an administrator for approval means telling them who is asking, so that one is only
// available to a caller who has already proven who they are. See `post_admin_auth_request`.
if AuthRequestType::from_i32(data.atype) == Some(AuthRequestType::AdminApproval) {
err!("You must be authenticated to create a request of that type")
}
let Some(user) = User::find_by_mail(&data.email, &conn).await else { let Some(user) = User::find_by_mail(&data.email, &conn).await else {
err!("AuthRequest doesn't exist", "User not found") err!("AuthRequest doesn't exist", "User not found")
}; };
@ -1584,8 +1877,14 @@ async fn post_auth_request(
_ => err!("AuthRequest doesn't exist", "Device verification failed"), _ => err!("AuthRequest doesn't exist", "Device verification failed"),
}; };
let Some(atype) = AuthRequestType::from_i32(data.atype) else {
err!("Unknown auth request type")
};
let mut auth_request = AuthRequest::new( let mut auth_request = AuthRequest::new(
user.uuid.clone(), user.uuid.clone(),
None,
atype,
data.device_identifier.clone(), data.device_identifier.clone(),
client_headers.device_type, client_headers.device_type,
client_headers.ip.ip.to_string(), client_headers.ip.ip.to_string(),
@ -1605,19 +1904,127 @@ async fn post_auth_request(
) )
.await; .await;
Ok(Json(json!({ Ok(Json(auth_request_json(&auth_request)))
"id": auth_request.uuid, }
"publicKey": auth_request.public_key,
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), /// Asks the administrators of every organization the user belongs to to let this device in.
"requestIpAddress": auth_request.request_ip, ///
"key": null, /// The way out for someone who unlocks with trusted devices and has no other device left to ask.
"masterPasswordHash": null, /// One request per organization, so whichever administrator gets there first can answer.
"creationDate": format_date(&auth_request.creation_date), /// https://github.com/bitwarden/server/blob/main/src/Api/Auth/Controllers/AuthRequestsController.cs
"responseDate": null, #[post("/auth-requests/admin-request", data = "<data>")]
"requestApproved": false, async fn post_admin_auth_request(data: Json<AuthRequestRequest>, headers: Headers, conn: DbConn) -> JsonResult {
"origin": CONFIG.domain_origin(), // Every call mails all administrators of every organization involved, so it is worth a limit of
"object": "auth-request" // 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")
}
// Only an organization the user really belongs to can answer for them. A pending invitation is
// not a membership yet, and a revoked one is not one anymore; sending either of them the email
// address, the address and the device of the asker is more than they are owed.
let memberships: Vec<Membership> = Membership::find_by_user(&headers.user.uuid, &conn)
.await
.into_iter()
.filter(|membership| membership.status == MembershipStatus::Confirmed as i32)
.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 {
// Asking again from the same device replaces the open request instead of adding one, so a
// client that retries does not pile up rows and does not mail the administrators twice.
let existing = AuthRequest::find_pending_admin_approval(
&headers.user.uuid,
&data.device_identifier,
&membership.org_uuid,
&conn,
)
.await;
let is_new = existing.is_none();
let mut auth_request = match existing {
Some(mut auth_request) => {
auth_request.access_code.clone_from(&data.access_code);
auth_request.public_key.clone_from(&data.public_key);
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;
};
let approvers = Membership::find_confirmed_by_org(org_id, conn)
.await
.into_iter()
.filter(|member| member.atype <= MembershipType::Admin as i32);
for approver in approvers {
let Some(admin) = User::find_by_uuid(&approver.user_uuid, conn).await else {
continue;
};
if let Err(e) =
mail::send_device_approval_requested(&admin.email, org_id, &org.name, &user.email, &user.name).await
{
error!("Error sending device approval request email: {e:#?}");
}
}
} }
#[get("/auth-requests/<auth_request_id>")] #[get("/auth-requests/<auth_request_id>")]
@ -1627,21 +2034,13 @@ async fn get_auth_request(auth_request_id: AuthRequestId, headers: Headers, conn
err!("AuthRequest doesn't exist", "Record not found or user uuid does not match") err!("AuthRequest doesn't exist", "Record not found or user uuid does not match")
}; };
let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date)); // The anonymous lookup refuses an expired request, and so does this one: the window an approval
// stays usable in should not depend on which of the two the client happens to poll.
if auth_request.is_expired() {
err!("AuthRequest doesn't exist", "Request has expired")
}
Ok(Json(json!({ Ok(Json(auth_request_json(&auth_request)))
"id": &auth_request_id,
"publicKey": auth_request.public_key,
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
"requestIpAddress": auth_request.request_ip,
"key": auth_request.enc_key,
"masterPasswordHash": auth_request.master_password_hash,
"creationDate": format_date(&auth_request.creation_date),
"responseDate": response_date_utc,
"requestApproved": auth_request.approved,
"origin": CONFIG.domain_origin(),
"object":"auth-request"
})))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -1668,6 +2067,13 @@ async fn put_auth_request(
err!("AuthRequest doesn't exist", "Record not found or user uuid does not match") err!("AuthRequest doesn't exist", "Record not found or user uuid does not match")
}; };
// A request addressed to an administrator is answered through the organization, where the
// permission to do so can actually be checked. Letting the asking user answer it here would
// make the whole detour pointless.
if auth_request.is_admin_approval() {
err!("AuthRequest doesn't exist", "Admin approval requests are answered by the organization")
}
if headers.device.uuid != data.device_identifier { if headers.device.uuid != data.device_identifier {
err!("AuthRequest doesn't exist", "Device verification failed") err!("AuthRequest doesn't exist", "Device verification failed")
} }
@ -1676,8 +2082,26 @@ async fn put_auth_request(
err!("An authentication request with the same device already exists") err!("An authentication request with the same device already exists")
} }
if auth_request.is_expired() {
err!("AuthRequest doesn't exist", "Request has expired")
}
// Only the newest request of a device may be approved. Anyone can create a request for a known
// device, so without this an older one could still be sitting there when the user approves what
// their screen shows, and the answer would go to whoever left it. Same check as upstream.
if data.request_approved
&& AuthRequest::find_by_user_and_requested_device(
&headers.user.uuid,
&auth_request.request_device_identifier,
&conn,
)
.await
.is_none_or(|newest| newest.uuid != auth_request.uuid)
{
err!("This request is no longer valid. Make sure to approve the most recent request.")
}
let response_date = Utc::now().naive_utc(); let response_date = Utc::now().naive_utc();
let response_date_utc = format_date(&response_date);
if data.request_approved { if data.request_approved {
auth_request.approved = Some(data.request_approved); auth_request.approved = Some(data.request_approved);
@ -1711,19 +2135,7 @@ async fn put_auth_request(
.await; .await;
} }
Ok(Json(json!({ Ok(Json(auth_request_json(&auth_request)))
"id": &auth_request_id,
"publicKey": auth_request.public_key,
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
"requestIpAddress": auth_request.request_ip,
"key": auth_request.enc_key,
"masterPasswordHash": auth_request.master_password_hash,
"creationDate": format_date(&auth_request.creation_date),
"responseDate": response_date_utc,
"requestApproved": auth_request.approved,
"origin": CONFIG.domain_origin(),
"object":"auth-request"
})))
} }
#[get("/auth-requests/<auth_request_id>/response?<code>")] #[get("/auth-requests/<auth_request_id>/response?<code>")]
@ -1744,21 +2156,11 @@ async fn get_auth_request_response(
err!("AuthRequest doesn't exist", "Invalid device, IP or code") err!("AuthRequest doesn't exist", "Invalid device, IP or code")
} }
let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date)); if auth_request.is_expired() {
err!("AuthRequest doesn't exist", "Request has expired")
}
Ok(Json(json!({ Ok(Json(auth_request_json(&auth_request)))
"id": &auth_request_id,
"publicKey": auth_request.public_key,
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
"requestIpAddress": auth_request.request_ip,
"key": auth_request.enc_key,
"masterPasswordHash": auth_request.master_password_hash,
"creationDate": format_date(&auth_request.creation_date),
"responseDate": response_date_utc,
"requestApproved": auth_request.approved,
"origin": CONFIG.domain_origin(),
"object":"auth-request"
})))
} }
// Now unused but not yet removed // Now unused but not yet removed
@ -1775,7 +2177,8 @@ async fn get_auth_requests_pending(headers: Headers, conn: DbConn) -> JsonResult
Ok(Json(json!({ Ok(Json(json!({
"data": auth_requests "data": auth_requests
.iter() .iter()
.filter(|request| request.approved.is_none()) // The same set a device answers for itself, see `find_by_user_and_requested_device`.
.filter(|request| request.approved.is_none() && !request.is_admin_approval() && !request.is_expired())
.map(|request| { .map(|request| {
let response_date_utc = request.response_date.map(|response_date| format_date(&response_date)); let response_date_utc = request.response_date.map(|response_date| format_date(&response_date));

329
src/api/core/organizations.rs

@ -1,5 +1,6 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use chrono::Utc;
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use rocket::{Route, serde::json::Json}; use rocket::{Route, serde::json::Json};
use serde_json::Value; use serde_json::Value;
@ -8,16 +9,17 @@ use crate::{
CONFIG, CONFIG,
api::admin::FAKE_ADMIN_UUID, api::admin::FAKE_ADMIN_UUID,
api::{ api::{
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, AnonymousNotify, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
}, },
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
db::{ db::{
DbConn, DbConn,
models::{ models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, AuthRequest, AuthRequestId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, CollectionUser, Device, DeviceType, EventType, Group, GroupId, GroupUser, Invitation, Membership,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey,
OrganizationId, User, UserId,
}, },
}, },
mail, mail,
@ -97,6 +99,10 @@ pub fn routes() -> Vec<Route> {
get_reset_password_details, get_reset_password_details,
put_reset_password, put_reset_password,
put_recover_account, put_recover_account,
get_organization_auth_requests,
deny_organization_auth_requests,
update_organization_auth_request,
update_many_organization_auth_requests,
get_org_export, get_org_export,
post_api_key, post_api_key,
rotate_api_key, rotate_api_key,
@ -3153,7 +3159,14 @@ async fn put_reset_password_enrollment(
err!("Reset password can't be withdrawn due to an enterprise policy"); err!("Reset password can't be withdrawn due to an enterprise policy");
} }
if reset_password_key.is_some() { // An account that unlocks with a trusted device has no master password to verify against, and
// the clients send nothing but the key when they enroll as part of that flow. Upstream carves
// out the same exception, keyed on the organization's SSO configuration rather than 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 { PasswordOrOtpData {
master_password_hash: reset_request.master_password_hash, master_password_hash: reset_request.master_password_hash,
otp: reset_request.otp, otp: reset_request.otp,
@ -3162,21 +3175,317 @@ async fn put_reset_password_enrollment(
.await?; .await?;
} }
membership.reset_password_key = reset_password_key; let enrolled = reset_password_key.is_some();
membership.save(&conn).await?; let membership_id = membership.uuid.clone();
// Enrolling is where a member who was invited into a trusted device organization turns into a
// real one; upstream accepts the invitation at this point as well. Without it they would stay
// invited forever 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?;
}
let event_type = if membership.reset_password_key.is_some() { let event_type = if enrolled {
EventType::OrganizationUserResetPasswordEnroll as i32 EventType::OrganizationUserResetPasswordEnroll as i32
} else { } else {
EventType::OrganizationUserResetPasswordWithdraw as i32 EventType::OrganizationUserResetPasswordWithdraw as i32
}; };
log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn) log_event(event_type, &membership_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await; .await;
Ok(()) Ok(())
} }
// Device approvals. A member who unlocks with a trusted device and has no other device of their own
// left to ask can turn to the administrators of their organization instead. Answering means handing
// them their own user key, encrypted for the key pair of the asking device, which is only possible
// because the member enrolled into account recovery beforehand.
// https://github.com/bitwarden/server/blob/main/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs
/// The requests waiting for an answer in this organization.
#[get("/organizations/<org_id>/auth-requests")]
async fn get_organization_auth_requests(org_id: OrganizationId, headers: AdminHeaders, 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 whose asker is not a confirmed member of this organization 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.status != MembershipStatus::Confirmed as i32 {
continue;
}
requests.push(auth_request.to_json_for_organization(&user.email, &member.uuid));
}
Ok(Json(json!({
"data": requests,
"continuationToken": null,
"object": "list"
})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AdminAuthRequestUpdateData {
request_approved: bool,
encrypted_user_key: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BulkDenyAuthRequestData {
ids: Vec<AuthRequestId>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationAuthRequestUpdateData {
id: AuthRequestId,
approved: bool,
key: Option<String>,
}
/// How many requests one call may answer. A screen full of pending approvals is a handful.
const MAX_BULK_AUTH_REQUESTS: usize = 500;
/// Whether one entry that cannot be answered takes the whole call down with it.
///
/// A single request is addressed by its id, so a caller that names 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/<org_id>/auth-requests/<request_id>", data = "<data>", rank = 2)]
async fn update_organization_auth_request(
org_id: OrganizationId,
request_id: AuthRequestId,
data: Json<AdminAuthRequestUpdateData>,
headers: AdminHeaders,
conn: DbConn,
ant: AnonymousNotify<'_>,
nt: Notify<'_>,
) -> EmptyResult {
let data = data.into_inner();
answer_organization_auth_request(
&org_id,
&request_id,
data.request_approved,
data.encrypted_user_key,
OnUnanswerable::Fail,
&headers,
&conn,
&ant,
&nt,
)
.await
}
#[post("/organizations/<org_id>/auth-requests/deny", data = "<data>", rank = 1)]
async fn deny_organization_auth_requests(
org_id: OrganizationId,
data: Json<BulkDenyAuthRequestData>,
headers: AdminHeaders,
conn: DbConn,
ant: AnonymousNotify<'_>,
nt: Notify<'_>,
) -> EmptyResult {
let ids = data.into_inner().ids;
if ids.len() > MAX_BULK_AUTH_REQUESTS {
err!(format!("At most {MAX_BULK_AUTH_REQUESTS} requests can be answered at once"))
}
for request_id in ids {
answer_organization_auth_request(
&org_id,
&request_id,
false,
None,
OnUnanswerable::Skip,
&headers,
&conn,
&ant,
&nt,
)
.await?;
}
Ok(())
}
#[post("/organizations/<org_id>/auth-requests", data = "<data>")]
async fn update_many_organization_auth_requests(
org_id: OrganizationId,
data: Json<Vec<OrganizationAuthRequestUpdateData>>,
headers: AdminHeaders,
conn: DbConn,
ant: AnonymousNotify<'_>,
nt: Notify<'_>,
) -> EmptyResult {
let updates = data.into_inner();
if updates.len() > MAX_BULK_AUTH_REQUESTS {
err!(format!("At most {MAX_BULK_AUTH_REQUESTS} requests can be answered at once"))
}
for update in updates {
answer_organization_auth_request(
&org_id,
&update.id,
update.approved,
update.key,
OnUnanswerable::Skip,
&headers,
&conn,
&ant,
&nt,
)
.await?;
}
Ok(())
}
#[expect(clippy::too_many_arguments, reason = "Rocket request guards have to be passed through")]
async fn answer_organization_auth_request(
org_id: &OrganizationId,
request_id: &AuthRequestId,
approved: bool,
encrypted_user_key: Option<String>,
on_unanswerable: OnUnanswerable,
headers: &AdminHeaders,
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, so it has to be one: an invitation
// that was never accepted is not a membership yet, and a revoked one is not one anymore.
let member = match Membership::find_by_user_and_org(&auth_request.user_uuid, org_id, conn).await {
Some(member) if member.status == MembershipStatus::Confirmed as i32 => 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 as i32
} else {
EventType::OrganizationUserRejectedAuthRequest as i32
};
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;
// The device that asked, not the one the administrator happens to be answering from: that one
// belongs to somebody else, and naming it here would both address the notification at a device
// of the wrong account and hand its identifiers to the push relay under a foreign user id.
if let Some(device) =
Device::find_by_uuid_and_user(&auth_request.request_device_identifier, &auth_request.user_uuid, conn).await
{
nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &device, conn).await;
}
if CONFIG.mail_enabled()
&& let Some(user) = User::find_by_uuid(&auth_request.user_uuid, conn).await
&& let Some(org) = Organization::find_by_uuid(org_id, conn).await
{
let device =
format!("{} - {}", DeviceType::from_i32(auth_request.device_type), auth_request.request_device_identifier);
let approved_at = auth_request.response_date.unwrap_or_else(|| Utc::now().naive_utc());
if let Err(e) = mail::send_trusted_device_admin_approval(
&user.email,
&org.name,
&approved_at,
&auth_request.request_ip,
&device,
)
.await
{
error!("Error sending trusted device approval email: {e:#?}");
}
}
Ok(())
}
// NOTE: It seems clients can't handle uppercase-first keys!! // NOTE: It seems clients can't handle uppercase-first keys!!
// We need to convert all keys so they have the first character to be a lowercase. // We need to convert all keys so they have the first character to be a lowercase.
// Else the export will be just an empty JSON file. // Else the export will be just an empty JSON file.
@ -3214,7 +3523,7 @@ async fn api_key(
let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await { let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await {
if rotate { if rotate {
org_api_key.api_key = crate::crypto::generate_api_key(); org_api_key.api_key = crate::crypto::generate_api_key();
org_api_key.revision_date = chrono::Utc::now().naive_utc(); org_api_key.revision_date = Utc::now().naive_utc();
org_api_key.save(&conn).await.expect("Error rotating organization API Key"); org_api_key.save(&conn).await.expect("Error rotating organization API Key");
} }
org_api_key org_api_key

3
src/api/core/sends.rs

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

119
src/api/identity.rs

@ -30,9 +30,9 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::{ models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, Membership,
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, MembershipStatus, MembershipType, OIDCCodeResponseError, OrganizationApiKey, OrganizationId, SendId,
TwoFactorType, User, UserId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
}, },
}, },
error::MapResult, error::MapResult,
@ -356,7 +356,7 @@ async fn sso_login(
// We passed 2FA get auth tokens // We passed 2FA get auth tokens
let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?; let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?;
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, true, conn, ip).await
} }
async fn password_login( async fn password_login(
@ -478,7 +478,78 @@ async fn password_login(
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, false, conn, ip).await
}
/// Whether offering the trusted device options can lead anywhere for this account.
///
/// Creating an account this way ends with enrolling into account recovery, which the clients do
/// unconditionally and which needs an organization to enroll into. An account that has nothing yet
/// and belongs to nowhere would therefore be shown the screen for a new account and get stuck
/// halfway through it, with its keys already written and its device still untrusted. Withholding
/// the options sends it to setting a master password instead, which works and leaves the door to
/// trusted devices open for the next login.
fn trusted_device_flow_is_completable(has_account_keys: bool, in_organization: bool) -> bool {
has_account_keys || in_organization
}
/// Trusted device encryption ("passwordless SSO"): instead of deriving the user key from a master
/// password, the client keeps a copy of it on the device, wrapped for a key pair that the device
/// generated. Its presence in the response is what makes the clients offer the flow at all.
///
/// Upstream ties this to the SSO configuration of an organization; Vaultwarden configures SSO for
/// the whole server, so `SSO_TRUSTED_DEVICE_ENCRYPTION` decides it here. Either way it stays an SSO
/// feature, a password login never gets these options.
/// https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs
async fn trusted_device_option(user: &User, device: &Device, conn: &DbConn) -> Option<Value> {
let enabled = CONFIG.sso_trusted_device_encryption();
// 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;
}
let memberships = Membership::find_by_user(&user.uuid, conn).await;
if !trusted_device_flow_is_completable(user.private_key.is_some(), !memberships.is_empty()) {
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());
// 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. Only a confirmed membership counts, 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.
let has_admin_approval = memberships.iter().any(|member| {
member.status == MembershipStatus::Confirmed as i32
&& member.reset_password_key.as_ref().is_some_and(|key| !key.is_empty())
});
// 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. Matches what
// `AdminHeaders` actually lets through.
let has_manage_reset_password_permission = memberships.iter().any(|member| {
member.status == MembershipStatus::Confirmed as i32 && member.atype <= MembershipType::Admin as i32
});
Some(json!({
"HasAdminApproval": has_admin_approval,
"HasLoginApprovingDevice": has_login_approving_device,
"HasManageResetPasswordPermission": has_manage_reset_password_permission,
"IsTdeOffboarding": offboarding,
"EncryptedPrivateKey": device.trusted_private_key(),
"EncryptedUserKey": device.trusted_user_key(),
"Object": "trustedDeviceUserDecryptionOption"
}))
} }
async fn authenticated_response( async fn authenticated_response(
@ -486,6 +557,7 @@ async fn authenticated_response(
device: &mut Device, device: &mut Device,
auth_tokens: auth::AuthTokens, auth_tokens: auth::AuthTokens,
twofactor_token: Option<String>, twofactor_token: Option<String>,
sso_login: bool,
conn: &DbConn, conn: &DbConn,
ip: &ClientIp, ip: &ClientIp,
) -> JsonResult { ) -> JsonResult {
@ -547,6 +619,16 @@ async fn authenticated_response(
Value::Null Value::Null
}; };
let mut user_decryption_options = json!({
"HasMasterPassword": has_master_password,
"MasterPasswordUnlock": master_password_unlock,
"Object": "userDecryptionOptions"
});
if sso_login && let Some(option) = trusted_device_option(user, device, conn).await {
user_decryption_options["TrustedDeviceOption"] = option;
}
let mut result = json!({ let mut result = json!({
"access_token": auth_tokens.access_token(), "access_token": auth_tokens.access_token(),
"expires_in": auth_tokens.expires_in(), "expires_in": auth_tokens.expires_in(),
@ -562,11 +644,7 @@ async fn authenticated_response(
"MasterPasswordPolicy": master_password_policy, "MasterPasswordPolicy": master_password_policy,
"scope": auth_tokens.scope(), "scope": auth_tokens.scope(),
"AccountKeys": account_keys, "AccountKeys": account_keys,
"UserDecryptionOptions": { "UserDecryptionOptions": user_decryption_options,
"HasMasterPassword": has_master_password,
"MasterPasswordUnlock": master_password_unlock,
"Object": "userDecryptionOptions"
},
}); });
if !user.akey.is_empty() { if !user.akey.is_empty() {
@ -1324,3 +1402,24 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure,
Ok(Redirect::temporary(String::from(auth_url))) Ok(Redirect::temporary(String::from(auth_url)))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_account_with_nothing_and_nowhere_to_go_is_not_offered_trusted_devices() {
// The one combination the clients cannot finish: nothing set up yet and no organization
// to enroll into.
assert!(!trusted_device_flow_is_completable(false, false));
// A brand new account that was invited somewhere can enroll, so the flow completes.
assert!(trusted_device_flow_is_completable(false, true));
// An account that is already set up does not go through account creation at all, with or
// without an organization. This covers the master password first route as well as an
// account that already trusts a device.
assert!(trusted_device_flow_is_completable(true, false));
assert!(trusted_device_flow_is_completable(true, true));
}
}

10
src/config.rs

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

270
src/db/models/auth_request.rs

@ -1,4 +1,4 @@
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, TimeDelta, Utc};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*; use diesel::prelude::*;
use serde_json::Value; use serde_json::Value;
@ -12,7 +12,7 @@ use crate::{
}; };
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{DeviceId, OrganizationId, UserId}; use super::{DeviceId, DeviceType, MembershipId, OrganizationId, UserId};
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)] #[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)]
#[diesel(table_name = auth_requests)] #[diesel(table_name = auth_requests)]
@ -22,6 +22,8 @@ pub struct AuthRequest {
pub uuid: AuthRequestId, pub uuid: AuthRequestId,
pub user_uuid: UserId, pub user_uuid: UserId,
pub organization_uuid: Option<OrganizationId>, pub organization_uuid: Option<OrganizationId>,
/// See `AuthRequestType`. Decides who may answer the request and how long it stays open.
pub atype: i32,
pub request_device_identifier: DeviceId, pub request_device_identifier: DeviceId,
pub device_type: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs pub device_type: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs
@ -42,9 +44,50 @@ pub struct AuthRequest {
pub authentication_date: Option<NaiveDateTime>, pub authentication_date: Option<NaiveDateTime>,
} }
/// https://github.com/bitwarden/server/blob/main/src/Core/Auth/Enums/AuthRequestType.cs
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AuthRequestType {
/// A new session asking one of the user's own devices to let it in.
AuthenticateAndUnlock = 0,
/// An existing session asking one of the user's own devices to unlock it.
Unlock = 1,
/// The user asking an administrator of their organization to let a device in, for when no
/// device of their own is around to ask.
AdminApproval = 2,
}
impl AuthRequestType {
pub fn from_i32(value: i32) -> Option<Self> {
match value {
0 => Some(AuthRequestType::AuthenticateAndUnlock),
1 => Some(AuthRequestType::Unlock),
2 => Some(AuthRequestType::AdminApproval),
_ => None,
}
}
}
impl AuthRequest { impl AuthRequest {
/// A request between the user's own devices is short lived, an administrator gets a week to
/// answer, and their answer stays usable for half a day. Same windows as upstream.
/// https://github.com/bitwarden/server/blob/main/src/Core/Settings/GlobalSettings.cs
pub fn user_request_expiration() -> TimeDelta {
TimeDelta::try_minutes(15).unwrap()
}
pub fn admin_request_expiration() -> TimeDelta {
TimeDelta::try_days(7).unwrap()
}
pub fn after_admin_approval_expiration() -> TimeDelta {
TimeDelta::try_hours(12).unwrap()
}
#[expect(clippy::too_many_arguments, reason = "Every field of the request is supplied by the caller")]
pub fn new( pub fn new(
user_uuid: UserId, user_uuid: UserId,
organization_uuid: Option<OrganizationId>,
atype: AuthRequestType,
request_device_identifier: DeviceId, request_device_identifier: DeviceId,
device_type: i32, device_type: i32,
request_ip: String, request_ip: String,
@ -56,7 +99,8 @@ impl AuthRequest {
Self { Self {
uuid: AuthRequestId(crate::util::get_uuid()), uuid: AuthRequestId(crate::util::get_uuid()),
user_uuid, user_uuid,
organization_uuid: None, organization_uuid,
atype: atype as i32,
request_device_identifier, request_device_identifier,
device_type, device_type,
@ -73,12 +117,50 @@ impl AuthRequest {
} }
} }
pub fn is_admin_approval(&self) -> bool {
self.atype == AuthRequestType::AdminApproval as i32
}
pub fn is_expired(&self) -> bool {
let now = Utc::now().naive_utc();
if self.is_admin_approval() {
// Once approved the clock restarts, so the user has time to come back and use it.
if let (Some(true), Some(response_date)) = (self.approved, self.response_date) {
return now > response_date + Self::after_admin_approval_expiration();
}
return now > self.creation_date + Self::admin_request_expiration();
}
now > self.creation_date + Self::user_request_expiration()
}
pub fn to_json_for_pending_device(&self) -> Value { pub fn to_json_for_pending_device(&self) -> Value {
json!({ json!({
"id": self.uuid, "id": self.uuid,
"creationDate": format_date(&self.creation_date), "creationDate": format_date(&self.creation_date),
}) })
} }
/// What an administrator gets to see about a request. Deliberately without the access code:
/// that one is the requesting device's proof, not something the answering side needs.
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,
"key": self.enc_key,
"creationDate": format_date(&self.creation_date),
"requestApproved": self.approved,
"responseDate": self.response_date.as_ref().map(format_date),
"object": "organizationAuthRequest",
})
}
} }
impl AuthRequest { impl AuthRequest {
@ -138,16 +220,27 @@ impl AuthRequest {
.await .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( pub async fn find_by_user_and_requested_device(
user_uuid: &UserId, user_uuid: &UserId,
device_uuid: &DeviceId, device_uuid: &DeviceId,
conn: &DbConn, conn: &DbConn,
) -> Option<Self> { ) -> Option<Self> {
let oldest = Utc::now().naive_utc() - Self::user_request_expiration();
conn.run(move |conn| { conn.run(move |conn| {
auth_requests::table auth_requests::table
.filter(auth_requests::user_uuid.eq(user_uuid)) .filter(auth_requests::user_uuid.eq(user_uuid))
.filter(auth_requests::request_device_identifier.eq(device_uuid)) .filter(auth_requests::request_device_identifier.eq(device_uuid))
.filter(auth_requests::atype.ne(AuthRequestType::AdminApproval as i32))
.filter(auth_requests::approved.is_null()) .filter(auth_requests::approved.is_null())
.filter(auth_requests::creation_date.gt(oldest))
.order_by(auth_requests::creation_date.desc()) .order_by(auth_requests::creation_date.desc())
.first::<Self>(conn) .first::<Self>(conn)
.ok() .ok()
@ -155,16 +248,62 @@ impl AuthRequest {
.await .await
} }
pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec<Self> { /// 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.
pub async fn find_pending_admin_approval(
user_uuid: &UserId,
device_uuid: &DeviceId,
org_uuid: &OrganizationId,
conn: &DbConn,
) -> Option<Self> {
conn.run(move |conn| { conn.run(move |conn| {
auth_requests::table auth_requests::table
.filter(auth_requests::creation_date.lt(dt)) .filter(auth_requests::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())
.order_by(auth_requests::creation_date.desc())
.first::<Self>(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<Self> {
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::<Self>(conn) .load::<Self>(conn)
.expect("Error loading auth_requests") .expect("Error loading auth_requests")
}) })
.await .await
} }
/// Bound to the organization on purpose: an administrator may only ever reach a request that
/// was addressed to their own organization.
pub async fn find_admin_approval_by_org_and_uuid(
uuid: &AuthRequestId,
org_uuid: &OrganizationId,
conn: &DbConn,
) -> Option<Self> {
conn.run(move |conn| {
auth_requests::table
.filter(auth_requests::uuid.eq(uuid))
.filter(auth_requests::organization_uuid.eq(org_uuid))
.filter(auth_requests::atype.eq(AuthRequestType::AdminApproval as i32))
.first::<Self>(conn)
.ok()
})
.await
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult { pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| { conn.run(move |conn| {
diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid))) diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid)))
@ -178,12 +317,56 @@ impl AuthRequest {
ct_eq(&self.access_code, access_code) ct_eq(&self.access_code, access_code)
} }
/// Drops everything past its window, which is a different one per type.
///
/// 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) { pub async fn purge_expired_auth_requests(conn: &DbConn) {
// delete auth requests older than 15 minutes which is functionally equivalent to upstream: let now = Utc::now().naive_utc();
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql let admin = AuthRequestType::AdminApproval as i32;
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap();
for auth_request in Self::find_created_before(&expiry_time, conn).await { let between_devices = now - Self::user_request_expiration();
auth_request.delete(conn).await.ok(); let for_an_admin = now - Self::admin_request_expiration();
let after_approval = now - Self::after_admin_approval_expiration();
let result = conn
.run(move |conn| -> EmptyResult {
// Between the user's own devices: 15 minutes from the moment it was asked.
let _: () = diesel::delete(
auth_requests::table
.filter(auth_requests::atype.ne(admin))
.filter(auth_requests::creation_date.lt(between_devices)),
)
.execute(conn)
.map_res("Error purging the expired auth requests")?;
// Approved by an administrator: half a day from the answer, so the user has time to
// come back and use it.
let _: () = diesel::delete(
auth_requests::table
.filter(auth_requests::atype.eq(admin))
.filter(auth_requests::approved.eq(true))
.filter(auth_requests::response_date.lt(after_approval)),
)
.execute(conn)
.map_res("Error purging the approved auth requests")?;
// Waiting for an administrator, or refused by one: a week from the moment it was
// asked either way, a refusal does not extend anything.
diesel::delete(
auth_requests::table
.filter(auth_requests::atype.eq(admin))
.filter(auth_requests::approved.is_null().or(auth_requests::approved.eq(false)))
.filter(auth_requests::creation_date.lt(for_an_admin)),
)
.execute(conn)
.map_res("Error purging the unanswered auth requests")
})
.await;
if let Err(e) = result {
error!("Error purging the expired auth requests: {e:#?}");
} }
} }
} }
@ -205,3 +388,70 @@ impl AuthRequest {
UuidFromParam, UuidFromParam,
)] )]
pub struct AuthRequestId(String); pub struct AuthRequestId(String);
#[cfg(test)]
mod tests {
use super::*;
fn request(atype: AuthRequestType, age: TimeDelta) -> AuthRequest {
let mut auth_request = AuthRequest::new(
String::from("user").into(),
None,
atype,
String::from("device").into(),
9,
String::from("127.0.0.1"),
String::from("code"),
String::from("2.public"),
);
auth_request.creation_date = Utc::now().naive_utc() - age;
auth_request
}
#[test]
fn 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 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);
}
}

220
src/db/models/device.rs

@ -33,6 +33,16 @@ pub struct Device {
pub refresh_token: String, pub refresh_token: String,
pub twofactor_remember: Option<String>, pub twofactor_remember: Option<String>,
// Trusted device encryption. The client generates a key pair per device plus a device key
// that never leaves the device, and stores the three resulting blobs here:
/// The user key, encrypted with `encrypted_public_key`. This is the copy of the user key that
/// lets the device unlock the vault without a master password.
pub encrypted_user_key: Option<String>,
/// The device public key, encrypted with the user key.
pub encrypted_public_key: Option<String>,
/// The device private key, encrypted with the device key. The server never sees the device key.
pub encrypted_private_key: Option<String>,
} }
/// Local methods /// Local methods
@ -53,6 +63,10 @@ impl Device {
push_token: None, push_token: None,
refresh_token: Device::generate_refresh_token(), refresh_token: Device::generate_refresh_token(),
twofactor_remember: None, twofactor_remember: None,
encrypted_user_key: None,
encrypted_public_key: None,
encrypted_private_key: None,
} }
} }
@ -61,6 +75,60 @@ impl Device {
crypto::encode_random_bytes::<64>(&BASE64URL) crypto::encode_random_bytes::<64>(&BASE64URL)
} }
/// A stored key is only usable when it is actually there and non-empty.
fn present(key: Option<&String>) -> Option<&String> {
key.filter(|key| !key.is_empty())
}
fn key_json(key: Option<&String>) -> Value {
match Self::present(key) {
Some(key) => Value::String(key.clone()),
None => Value::Null,
}
}
/// Whether this device holds everything needed to unlock the vault on its own.
///
/// A client can drop its device key without telling us, so this only says that the server side
/// of the trust is complete. See `DeviceExtensions.IsTrusted` upstream.
pub fn is_trusted(&self) -> bool {
Self::present(self.encrypted_user_key.as_ref()).is_some()
&& Self::present(self.encrypted_public_key.as_ref()).is_some()
&& Self::present(self.encrypted_private_key.as_ref()).is_some()
}
/// The wrapped user key, but only while the whole trust is intact. Handing out one half of an
/// incomplete set would just make the client fail later in the unlock.
pub fn trusted_user_key(&self) -> Option<&String> {
self.is_trusted().then_some(self.encrypted_user_key.as_ref()).flatten()
}
pub fn trusted_private_key(&self) -> Option<&String> {
self.is_trusted().then_some(self.encrypted_private_key.as_ref()).flatten()
}
/// Whether the device still holds the private key of its own key pair.
///
/// That key is wrapped with the device key, which a 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()
}
/// Whether any part of a trust is stored, complete or not.
pub fn holds_any_key(&self) -> bool {
Self::present(self.encrypted_user_key.as_ref()).is_some()
|| Self::present(self.encrypted_public_key.as_ref()).is_some()
|| self.holds_private_key()
}
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 { pub fn to_json(&self) -> Value {
json!({ json!({
"id": self.uuid, "id": self.uuid,
@ -68,11 +136,28 @@ impl Device {
"type": self.atype, "type": self.atype,
"identifier": self.uuid, "identifier": self.uuid,
"creationDate": format_date(&self.created_at), "creationDate": format_date(&self.created_at),
"isTrusted": false, "isTrusted": self.is_trusted(),
"encryptedUserKey": Self::key_json(self.encrypted_user_key.as_ref()),
"encryptedPublicKey": Self::key_json(self.encrypted_public_key.as_ref()),
"object":"device" "object":"device"
}) })
} }
/// Response of `POST /devices/<identifier>/retrieve-keys`, used by the clients to re-wrap the
/// user key for every trusted device during a key rotation.
pub fn to_protected_json(&self) -> Value {
json!({
"id": self.uuid,
"name": self.name,
"type": self.atype,
"identifier": self.uuid,
"creationDate": format_date(&self.created_at),
"encryptedUserKey": Self::key_json(self.encrypted_user_key.as_ref()),
"encryptedPublicKey": Self::key_json(self.encrypted_public_key.as_ref()),
"object": "protectedDevice"
})
}
pub fn refresh_twofactor_remember(&mut self) -> String { pub fn refresh_twofactor_remember(&mut self) -> String {
use crate::auth::{encode_jwt, generate_2fa_remember_claims}; use crate::auth::{encode_jwt, generate_2fa_remember_claims};
@ -123,9 +208,9 @@ impl DeviceWithAuthRequest {
"identifier": self.device.uuid, "identifier": self.device.uuid,
"creationDate": format_date(&self.device.created_at), "creationDate": format_date(&self.device.created_at),
"devicePendingAuthRequest": auth_request, "devicePendingAuthRequest": auth_request,
"isTrusted": false, "isTrusted": self.device.is_trusted(),
"encryptedPublicKey": null, "encryptedPublicKey": Device::key_json(self.device.encrypted_public_key.as_ref()),
"encryptedUserKey": null, "encryptedUserKey": Device::key_json(self.device.encrypted_user_key.as_ref()),
"object": "device", "object": "device",
}) })
} }
@ -177,6 +262,29 @@ impl Device {
.await .await
} }
/// Invalidates every copy of the user key that is wrapped for one of the user's devices.
///
/// Called when the user key itself is replaced, 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::<Option<String>>(None),
devices::encrypted_public_key.eq::<Option<String>>(None),
))
.execute(conn)
.map_res("Error invalidating the wrapped user keys of the devices")
})
.await
}
pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> { pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| { conn.run(move |conn| {
devices::table devices::table
@ -364,6 +472,18 @@ impl DeviceType {
_ => DeviceType::UnknownBrowser, _ => DeviceType::UnknownBrowser,
} }
} }
/// Whether a device of this type can answer a login request from another device.
///
/// The SDK, the server and the CLIs have no interactive prompt to show the request in, so they
/// are the ones left out. Matches `LoginApprovingClientTypes` upstream, 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( #[derive(
@ -373,3 +493,95 @@ pub struct DeviceId(String);
#[derive(Clone, Debug, DieselNewType, Display, From, FromForm, Serialize, Deserialize, UuidFromParam)] #[derive(Clone, Debug, DieselNewType, Display, From, FromForm, Serialize, Deserialize, UuidFromParam)]
pub struct PushId(pub String); pub struct PushId(pub String);
#[cfg(test)]
mod tests {
use super::*;
fn trusted_device() -> Device {
let mut device = Device::new(String::from("device").into(), String::from("user").into(), String::new(), 9);
device.encrypted_user_key = Some(String::from("2.user"));
device.encrypted_public_key = Some(String::from("2.public"));
device.encrypted_private_key = Some(String::from("2.private"));
device
}
#[test]
fn a_device_is_only_trusted_with_all_three_keys() {
assert!(trusted_device().is_trusted());
let keys: [fn(&mut Device) -> &mut Option<String>; 3] = [
|device| &mut device.encrypted_user_key,
|device| &mut device.encrypted_public_key,
|device| &mut device.encrypted_private_key,
];
for key in keys {
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");
assert!(device.holds_any_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());
assert!(!device.holds_any_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 untrusting_clears_every_key() {
let mut device = trusted_device();
device.untrust();
assert!(!device.is_trusted());
assert!(!device.holds_any_key());
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})");
}
}
}

2
src/db/models/mod.rs

@ -20,7 +20,7 @@ mod user;
pub use self::archive::Archive; pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId}; pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId}; pub use self::auth_request::{AuthRequest, AuthRequestId, AuthRequestType};
pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::cipher::{Cipher, CipherId, RepromptType};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};

4
src/db/schema.rs

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

51
src/mail.rs

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

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

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

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

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

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

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

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

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

89
src/util.rs

@ -543,6 +543,95 @@ pub fn is_valid_email(email: &str) -> bool {
true true
} }
/// The most an `EncString` we are willing to store may weigh. The largest legitimate one is an
/// RSA-4096 envelope with a MAC, which stays an order of magnitude below this.
const MAX_ENC_STRING_LENGTH: usize = 4096;
/// Whether a value has the shape of a Bitwarden `EncString`: `<type>.<part>|<part>...`.
///
/// The server cannot tell whether a blob decrypts, but it can refuse everything that is not even
/// of the right form, which keeps unbounded junk out of the columns that hold key material.
/// Mirrors `EncryptedStringAttribute` upstream.
/// 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 Some((enc_type, data)) = value.split_once('.') else {
return false;
};
// The number of `|` separated parts each type is made of.
let parts = match enc_type {
// An RSA envelope is the ciphertext by itself.
"3" | "4" => 1,
// An AES value carries its IV, an RSA one from type 5 on carries a MAC.
"0" | "5" | "6" => 2,
// And an AES value from type 1 on carries both.
"1" | "2" => 3,
_ => return false,
};
let mut seen = 0;
for part in data.split('|') {
seen += 1;
if seen > parts || part.is_empty() || data_encoding::BASE64.decode(part.as_bytes()).is_err() {
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",
"1.aXY=|Y2lwaGVy|bWFj",
"2.aXY=|Y2lwaGVy|bWFj",
"3.Y2lwaGVy",
"4.Y2lwaGVy",
"5.Y2lwaGVy|bWFj",
"6.Y2lwaGVy|bWFj",
] {
assert!(is_valid_enc_string(value), "{value}");
}
}
#[test]
fn anything_that_is_not_one_is_refused() {
for value in [
"",
" ",
"not-an-enc-string",
"2",
"2.",
".aXY=|Y2lwaGVy|bWFj",
"7.Y2lwaGVy", // no such type
"-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
"2.aXY=||bWFj", // an empty part is not base64
"4.not base64!",
] {
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");
}
}
// //
// Deployment environment methods // Deployment environment methods
// //

Loading…
Cancel
Save