Browse Source

Merge 1e743fcec3 into a6c3bd6d18

pull/7534/merge
Tom 9 hours ago
committed by GitHub
parent
commit
2201e41203
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 15
      .env.template
  2. 6
      .typos.toml
  3. 3
      migrations/mysql/2026-07-31-120000_add_device_trusted_encryption/down.sql
  4. 3
      migrations/mysql/2026-07-31-120000_add_device_trusted_encryption/up.sql
  5. 1
      migrations/mysql/2026-07-31-130000_add_auth_request_type/down.sql
  6. 1
      migrations/mysql/2026-07-31-130000_add_auth_request_type/up.sql
  7. 2
      migrations/mysql/2026-08-01-120000_add_auth_request_indexes/down.sql
  8. 2
      migrations/mysql/2026-08-01-120000_add_auth_request_indexes/up.sql
  9. 3
      migrations/postgresql/2026-07-31-120000_add_device_trusted_encryption/down.sql
  10. 3
      migrations/postgresql/2026-07-31-120000_add_device_trusted_encryption/up.sql
  11. 1
      migrations/postgresql/2026-07-31-130000_add_auth_request_type/down.sql
  12. 1
      migrations/postgresql/2026-07-31-130000_add_auth_request_type/up.sql
  13. 2
      migrations/postgresql/2026-08-01-120000_add_auth_request_indexes/down.sql
  14. 2
      migrations/postgresql/2026-08-01-120000_add_auth_request_indexes/up.sql
  15. 3
      migrations/sqlite/2026-07-31-120000_add_device_trusted_encryption/down.sql
  16. 3
      migrations/sqlite/2026-07-31-120000_add_device_trusted_encryption/up.sql
  17. 1
      migrations/sqlite/2026-07-31-130000_add_auth_request_type/down.sql
  18. 1
      migrations/sqlite/2026-07-31-130000_add_auth_request_type/up.sql
  19. 2
      migrations/sqlite/2026-08-01-120000_add_auth_request_indexes/down.sql
  20. 2
      migrations/sqlite/2026-08-01-120000_add_auth_request_indexes/up.sql
  21. 881
      src/api/core/accounts.rs
  22. 338
      src/api/core/organizations.rs
  23. 3
      src/api/core/sends.rs
  24. 390
      src/api/identity.rs
  25. 9
      src/api/notifications.rs
  26. 16
      src/api/push.rs
  27. 35
      src/auth.rs
  28. 10
      src/config.rs
  29. 299
      src/db/models/auth_request.rs
  30. 270
      src/db/models/device.rs
  31. 2
      src/db/models/event.rs
  32. 2
      src/db/models/mod.rs
  33. 235
      src/db/models/organization.rs
  34. 4
      src/db/schema.rs
  35. 51
      src/mail.rs
  36. 6
      src/static/templates/email/device_approval_requested.hbs
  37. 16
      src/static/templates/email/device_approval_requested.html.hbs
  38. 9
      src/static/templates/email/trusted_device_admin_approval.hbs
  39. 22
      src/static/templates/email/trusted_device_admin_approval.html.hbs
  40. 162
      src/util.rs

15
.env.template

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

6
.typos.toml

@ -14,9 +14,9 @@ extend-ignore-re = [
# In SMTP it's called HELO, so ignore it
"(?i)helo_name",
"Server name sent during.+HELO",
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
"COSEKey",
"COSEAlgorithm",
# COSE Is short for CBOR Object Signing and Encryption, which covers the type names taken from
# it as well as the acronym on its own
"COSE",
# Ignore this specific string as it's valid
"Ensure they are valid OTPs",
# This word is misspelled upstream

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

881
src/api/core/accounts.rs

File diff suppressed because it is too large

338
src/api/core/organizations.rs

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

3
src/api/core/sends.rs

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

390
src/api/identity.rs

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

9
src/api/notifications.rs

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

16
src/api/push.rs

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

35
src/auth.rs

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

10
src/config.rs

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

299
src/db/models/auth_request.rs

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

270
src/db/models/device.rs

@ -33,6 +33,16 @@ pub struct Device {
pub refresh_token: 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
@ -53,6 +63,10 @@ impl Device {
push_token: None,
refresh_token: Device::generate_refresh_token(),
twofactor_remember: None,
encrypted_user_key: None,
encrypted_public_key: None,
encrypted_private_key: None,
}
}
@ -61,6 +75,47 @@ impl Device {
crypto::encode_random_bytes::<64>(&BASE64URL)
}
/// A stored key is only usable when it is actually there and non-empty.
fn present(key: Option<&String>) -> Option<&String> {
key.filter(|key| !key.is_empty())
}
fn key_json(key: Option<&String>) -> Value {
match Self::present(key) {
Some(key) => Value::String(key.clone()),
None => Value::Null,
}
}
/// Whether this device holds everything needed to unlock the vault on its own.
///
/// A client can drop its device key without telling us, so this only says that the server side
/// of the trust is complete. See `DeviceExtensions.IsTrusted` upstream.
pub fn is_trusted(&self) -> bool {
Self::present(self.encrypted_user_key.as_ref()).is_some()
&& Self::present(self.encrypted_public_key.as_ref()).is_some()
&& Self::present(self.encrypted_private_key.as_ref()).is_some()
}
/// The wrapped user key, but only while the whole trust is intact. Handing out one half of an
/// incomplete set would just make the client fail later in the unlock.
pub fn trusted_user_key(&self) -> Option<&String> {
self.is_trusted().then_some(self.encrypted_user_key.as_ref()).flatten()
}
pub fn trusted_private_key(&self) -> Option<&String> {
self.is_trusted().then_some(self.encrypted_private_key.as_ref()).flatten()
}
/// Whether the device still holds the private key of its own key pair.
///
/// That key is wrapped with the device key, which a rotation of the user key does not touch, so
/// it outlives one. It is what decides whether a device can be handed a freshly wrapped user
/// key and be trusted again, or whether it has to be set up from scratch.
pub fn holds_private_key(&self) -> bool {
Self::present(self.encrypted_private_key.as_ref()).is_some()
}
pub fn to_json(&self) -> Value {
json!({
"id": self.uuid,
@ -68,11 +123,28 @@ impl Device {
"type": self.atype,
"identifier": self.uuid,
"creationDate": format_date(&self.created_at),
"isTrusted": false,
"isTrusted": self.is_trusted(),
"encryptedUserKey": Self::key_json(self.encrypted_user_key.as_ref()),
"encryptedPublicKey": Self::key_json(self.encrypted_public_key.as_ref()),
"object":"device"
})
}
/// Response of `POST /devices/<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 {
use crate::auth::{encode_jwt, generate_2fa_remember_claims};
@ -123,9 +195,9 @@ impl DeviceWithAuthRequest {
"identifier": self.device.uuid,
"creationDate": format_date(&self.device.created_at),
"devicePendingAuthRequest": auth_request,
"isTrusted": false,
"encryptedPublicKey": null,
"encryptedUserKey": null,
"isTrusted": self.device.is_trusted(),
"encryptedPublicKey": Device::key_json(self.device.encrypted_public_key.as_ref()),
"encryptedUserKey": Device::key_json(self.device.encrypted_user_key.as_ref()),
"object": "device",
})
}
@ -177,6 +249,106 @@ impl Device {
.await
}
/// Invalidates every copy of the user key that is wrapped for one of the user's devices.
///
/// Called when the user key itself is replaced and the client did not say what to put in their
/// place, which leaves all of those copies pointing at a key that no longer unlocks anything. No
/// device counts as trusted afterwards, so a client that stops here ends up with an extra login
/// rather than a broken unlock. The device key pairs are deliberately left alone: they are
/// wrapped with the device key, which a rotation does not touch, so `POST /devices/update-trust`
/// can hand every device the new user key and restore its trust. Whatever it does not list is
/// dropped there.
///
/// One statement, so there is no half applied state to reason about.
pub async fn invalidate_wrapped_user_keys(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::update(devices::table.filter(devices::user_uuid.eq(user_uuid)))
.set((
devices::encrypted_user_key.eq::<Option<String>>(None),
devices::encrypted_public_key.eq::<Option<String>>(None),
))
.execute(conn)
.map_res("Error invalidating the wrapped user keys of the devices")
})
.await
}
/// Drops every stored key of the named devices, in one statement so it cannot half apply.
///
/// The caller has already checked that each id belongs to this user.
pub async fn untrust_many(user_uuid: &UserId, device_ids: Vec<DeviceId>, conn: &DbConn) -> EmptyResult {
if device_ids.is_empty() {
return Ok(());
}
conn.run(move |conn| {
diesel::update(
devices::table.filter(devices::user_uuid.eq(user_uuid)).filter(devices::uuid.eq_any(device_ids)),
)
.set((
devices::encrypted_user_key.eq::<Option<String>>(None),
devices::encrypted_public_key.eq::<Option<String>>(None),
devices::encrypted_private_key.eq::<Option<String>>(None),
))
.execute(conn)
.map_res("Error untrusting the devices")
})
.await
}
/// Replaces the trust of every device of the user in one go: the listed ones are re-wrapped for
/// the current user key, everything else loses whatever it still holds.
///
/// This is what both a key rotation and `POST /devices/update-trust` come down to. The caller
/// has already checked that every id belongs to this user, that none is listed twice, and that
/// no device is asked to keep a trust it cannot complete; this only writes.
///
/// One transaction, so the devices cannot be left split between the old and the new user key,
/// which is a state no client can tell apart from a working one until an unlock fails.
pub async fn replace_trust(
user_uuid: &UserId,
updates: Vec<(DeviceId, String, String)>,
conn: &DbConn,
) -> EmptyResult {
conn.run(move |conn| {
conn.transaction(|conn| -> EmptyResult {
let keep: Vec<DeviceId> = updates.iter().map(|(device_id, ..)| device_id.clone()).collect();
// Whatever the untouched devices hold wraps the previous user key, or is one half of
// a trust that was never finished. Either way it unlocks nothing and must not stay.
let cleared = (
devices::encrypted_user_key.eq::<Option<String>>(None),
devices::encrypted_public_key.eq::<Option<String>>(None),
devices::encrypted_private_key.eq::<Option<String>>(None),
);
let outdated = devices::table.filter(devices::user_uuid.eq(&user_uuid));
let _: () = if keep.is_empty() {
diesel::update(outdated).set(cleared).execute(conn)
} else {
diesel::update(outdated.filter(devices::uuid.ne_all(keep))).set(cleared).execute(conn)
}
.map_res("Error untrusting the devices left out of the rotation")?;
// The device key pair is deliberately not touched here: it is wrapped with the
// device key, which the server never sees and a rotation never changes.
for (device_id, encrypted_user_key, encrypted_public_key) in updates {
let _: () = diesel::update(
devices::table.filter(devices::uuid.eq(device_id)).filter(devices::user_uuid.eq(&user_uuid)),
)
.set((
devices::encrypted_user_key.eq(Some(encrypted_user_key)),
devices::encrypted_public_key.eq(Some(encrypted_public_key)),
))
.execute(conn)
.map_res("Error rotating the wrapped user key of a device")?;
}
Ok(())
})
})
.await
}
pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| {
devices::table
@ -364,6 +536,18 @@ impl DeviceType {
_ => DeviceType::UnknownBrowser,
}
}
/// Whether a device of this type can answer a login request from another device.
///
/// The SDK, the server and the CLIs have no interactive prompt to show the request in, so they
/// are the ones left out. Matches `LoginApprovingClientTypes` upstream, which allows the
/// desktop, mobile, web and browser client types.
pub fn can_approve_login_requests(&self) -> bool {
!matches!(
self,
DeviceType::Sdk | DeviceType::Server | DeviceType::WindowsCLI | DeviceType::MacOsCLI | DeviceType::LinuxCLI
)
}
}
#[derive(
@ -373,3 +557,81 @@ pub struct DeviceId(String);
#[derive(Clone, Debug, DieselNewType, Display, From, FromForm, Serialize, Deserialize, UuidFromParam)]
pub struct PushId(pub String);
#[cfg(test)]
mod tests {
use super::*;
fn trusted_device() -> Device {
let mut device = Device::new(String::from("device").into(), String::from("user").into(), String::new(), 9);
device.encrypted_user_key = Some(String::from("2.user"));
device.encrypted_public_key = Some(String::from("2.public"));
device.encrypted_private_key = Some(String::from("2.private"));
device
}
#[test]
fn a_device_is_only_trusted_with_all_three_keys() {
assert!(trusted_device().is_trusted());
let keys: [fn(&mut Device) -> &mut Option<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");
}
#[test]
fn a_device_that_never_had_a_trust_holds_nothing() {
let device = Device::new(String::from("device").into(), String::from("user").into(), String::new(), 9);
assert!(!device.holds_private_key());
let mut device = trusted_device();
device.encrypted_private_key = Some(String::new());
assert!(!device.holds_private_key(), "an empty key is as good as a missing one");
}
#[test]
fn only_interactive_clients_can_approve_a_login_request() {
for atype in 0..=26 {
let device_type = DeviceType::from_i32(atype);
let expected = !matches!(atype, 21..=25);
assert_eq!(device_type.can_approve_login_requests(), expected, "device type {atype} ({device_type})");
}
}
}

2
src/db/models/event.rs

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

2
src/db/models/mod.rs

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

235
src/db/models/organization.rs

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

4
src/db/schema.rs

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

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 }}

162
src/util.rs

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

Loading…
Cancel
Save