diff --git a/.env.template b/.env.template index 008fa09d..3b10dd26 100644 --- a/.env.template +++ b/.env.template @@ -381,6 +381,7 @@ ## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) ## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0) ## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0) +## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0) ## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0) ## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0) ## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0) diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts index 35dfcdb1..2be5fec1 100644 --- a/playwright/tests/organization.smtp.spec.ts +++ b/playwright/tests/organization.smtp.spec.ts @@ -40,6 +40,16 @@ test('Invite users', async ({ page }) => { await createAccount(test, page, users.user1, mail1Buffer); await orgs.create(test, page, 'Test'); + + await test.step(`Set account recovery`, async () => { + await orgs.policies(test, page, 'Test'); + await page.getByRole('button', { name: 'Account recovery' }).click(); + await page.getByRole('checkbox', { name: 'Turn on' }).check(); + await page.getByRole('checkbox', { name: 'Require new members' }).check(); + await page.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page, 'Edited policy Account recovery'); + }); + await orgs.members(test, page, 'Test'); await orgs.invite(test, page, 'Test', users.user2.email); await orgs.invite(test, page, 'Test', users.user3.email, { @@ -117,3 +127,27 @@ test('Organization is visible', async ({ page }) => { await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); }); + +test('Recover user password', async ({ page }) => { + await logUser(test, page, users.user1, mail1Buffer); + + let newPassword = "TotoNewPassword"; + + await orgs.members(test, page, 'Test'); + await test.step(`Rrcover ${users.user2.email}`, async () => { + await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); + await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click(); + await page.getByRole('menuitem', { name: 'Recover account' }).click(); + await page.getByRole('textbox', { name: 'New master password (required)', exact: true }).fill(newPassword); + await page.getByRole('textbox', { name: 'Confirm new master password (' }).fill(newPassword); + await page.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page, 'Password reset success'); + }); + + let user2 = { + email: users.user2.email, + name: users.user2.name, + password: newPassword, + }; + await logUser(test, page, user2, mail2Buffer); +}); diff --git a/playwright/tests/send.spec.ts b/playwright/tests/send.spec.ts new file mode 100644 index 00000000..d27c3ffc --- /dev/null +++ b/playwright/tests/send.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, type Page, type TestInfo } from '@playwright/test'; +import * as OTPAuth from "otpauth"; + +import * as utils from "../global-utils"; +import { createAccount } from './setups/user'; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo, {}); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); +}); + +test('Send', async ({ browser, page }) => { + await createAccount(test, page, users.user1); + + const send_url = await test.step('Create', async () => { + await page.getByRole('link', { name: 'Send' }).click(); + await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Text' }).click(); + + await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Test'); + await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('test'); + await page.getByRole('button', { name: 'Save' }).click(); + + await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); + + return await page.evaluate(() => navigator.clipboard.readText()); + }); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + await test.step('View', async () => { + await page2.goto(send_url, { waitUntil: 'domcontentloaded' }); + await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); + await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible(); + }); + + const pwd_url = await test.step('Create with password', async () => { + await page.getByRole('link', { name: 'Send' }).click(); + await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Text' }).click(); + + await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Password'); + await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('password'); + await page.getByRole('combobox', { name: 'Who can view' }).click(); + await page.getByText('Anyone with a password set by you').click(); + await page.getByRole('textbox', { name: 'Password (required)' }).fill('password'); + + await page.getByRole('button', { name: 'Save' }).click(); + await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); + + return await page.evaluate(() => navigator.clipboard.readText()); + }); + + await test.step('View with password', async () => { + await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' }); + await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible(); + await page2.getByRole('textbox', { name: 'Password (required)' }).fill('password'); + await page2.getByRole('button', { name: 'Continue' }).click(); + await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); + await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible(); + }); +}); diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 954b35bd..623edf24 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -97,14 +97,11 @@ pub struct RegisterData { email: String, #[serde(flatten)] - kdf: KDFData, + compat: RegisterDataCompat, - #[serde(alias = "userSymmetricKey")] - key: String, #[serde(alias = "userAsymmetricKeys")] keys: Option, - master_password_hash: String, master_password_hint: Option, name: Option, @@ -119,6 +116,102 @@ pub struct RegisterData { org_invite_token: Option, } +impl RegisterData { + fn hash(&self) -> String { + self.compat.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned() + } + + fn kdf(&self) -> &KDFData { + self.compat.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf) + } + + fn key(&self) -> String { + self.compat.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned() + } + + // When comparing with salt, email need to be normalized: + // - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171 + fn unprocessable(&self) -> bool { + let mut unprocessable = false; + *self.compat.fold( + |_| &false, + |rdcu| { + let email = self.email.trim().to_lowercase(); + unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf + || rdcu.master_password_authentication.salt != email + || rdcu.master_password_unlock.salt != email; + &unprocessable + }, + ) + } +} + +#[derive(Debug, Deserialize)] +struct RegisterDataOld { + #[serde(flatten)] + kdf: KDFData, + + #[serde(alias = "userSymmetricKey")] + key: String, + + #[serde(alias = "masterPasswordHash")] + master_password_hash: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RegisterDataCur { + master_password_authentication: MasterPasswordAuthentication, + master_password_unlock: MasterPasswordUnlock, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RegisterDataCompat { + RegisterDataOld(RegisterDataOld), + RegisterDataCur(RegisterDataCur), +} + +impl RegisterDataCompat { + fn fold<'a, T>( + &'a self, + fct: impl FnOnce(&'a RegisterDataOld) -> &'a T, + fcu: impl FnOnce(&'a RegisterDataCur) -> &'a T, + ) -> &'a T { + match self { + RegisterDataCompat::RegisterDataOld(rdc) => fct(rdc), + RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KeysData { + encrypted_private_key: String, + public_key: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MasterPasswordAuthentication { + kdf: KDFData, + salt: String, + + #[serde(alias = "masterPasswordAuthenticationHash")] + hash: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MasterPasswordUnlock { + kdf: KDFData, + salt: String, + + #[serde(alias = "masterKeyWrappedUserKey")] + key: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetPasswordData { @@ -132,13 +225,6 @@ pub struct SetPasswordData { org_identifier: Option, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KeysData { - encrypted_private_key: String, - public_key: String, -} - /// Trims whitespace from password hints, and converts blank password hints to `None`. fn clean_password_hint(password_hint: Option<&String>) -> Option { match password_hint { @@ -177,6 +263,10 @@ pub async fn register(data: Json, email_verification: bool, conn: let mut pending_emergency_access = None; + if data.unprocessable() { + err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code); + } + // First, validate the provided verification tokens if email_verification { match ( @@ -257,8 +347,8 @@ pub async fn register(data: Json, email_verification: bool, conn: err!("Registration not allowed or user already exists") } - if let Some(token) = data.org_invite_token { - let claims = decode_invite(&token)?; + if let Some(token) = data.org_invite_token.as_ref() { + let claims = decode_invite(token)?; if claims.email == email { // Verify the email address when signing up via a valid invite token email_verified = true; @@ -296,9 +386,9 @@ pub async fn register(data: Json, email_verification: bool, conn: // Make sure we don't leave a lingering invitation. Invitation::take(&email, &conn).await; - set_kdf_data(&mut user, &data.kdf)?; + set_kdf_data(&mut user, data.kdf())?; - user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?; + user.set_password(&data.hash(), Some(data.key()), true, None, &conn).await?; user.password_hint = password_hint; // Add extra fields if present diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index dd68cd5b..68c9c1c5 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -96,6 +96,7 @@ pub fn routes() -> Vec { put_reset_password_enrollment, get_reset_password_details, put_reset_password, + put_recover_account, get_org_export, post_api_key, rotate_api_key, @@ -1089,9 +1090,13 @@ async fn send_invite( err!(format!("User already in organization: {email}")) } - // automatically accept existing users if mail is disabled - if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { - member_status = MembershipStatus::Accepted as i32; + if !CONFIG.mail_enabled() { + if user.password_hash.is_empty() { + Invitation::new(email).save(&conn).await?; + } else { + // automatically accept existing users if mail is disabled + member_status = MembershipStatus::Accepted as i32; + } } user } @@ -1713,6 +1718,15 @@ async fn delete_member_impl( if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await { nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await; + + if !CONFIG.mail_enabled() + && !Membership::find_invited_by_user(&user.uuid, conn) + .await + .into_iter() + .any(|m| m.uuid != member_to_delete.uuid) + { + Invitation::take(&user.email, conn).await; + } } member_to_delete.delete(conn).await @@ -2020,18 +2034,27 @@ struct PolicyData { data: Option, } +#[derive(Deserialize)] +struct PutPolicy { + policy: PolicyData, + // Ignore metadata for now as we do not yet support this + // "metadata": { + // "defaultUserCollectionName": "2.xx|xx==|xx=" + // } +} + #[put("/organizations//policies/", data = "")] async fn put_policy( org_id: OrganizationId, pol_type: i32, - data: Json, + data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - let data: PolicyData = data.into_inner(); + let data: PolicyData = data.into_inner().policy; let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { err!("Invalid or unsupported policy type") @@ -2139,26 +2162,16 @@ async fn put_policy( Ok(Json(policy.to_json())) } -#[derive(Deserialize)] -struct PolicyDataVnext { - policy: PolicyData, - // Ignore metadata for now as we do not yet support this - // "metadata": { - // "defaultUserCollectionName": "2.xx|xx==|xx=" - // } -} - +// Deprecated with client v2026.5.0 #[put("/organizations//policies//vnext", data = "")] async fn put_policy_vnext( org_id: OrganizationId, pol_type: i32, - data: Json, + data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { - let data: PolicyDataVnext = data.into_inner(); - let policy: PolicyData = data.policy; - put_policy(org_id, pol_type, Json(policy), headers, conn).await + put_policy(org_id, pol_type, data, headers, conn).await } #[get("/plans")] @@ -2875,9 +2888,14 @@ struct OrganizationUserResetPasswordEnrollmentRequest { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct OrganizationUserResetPasswordRequest { +struct OrganizationUserRecoverAccountRequest { new_master_password_hash: String, key: String, + + #[serde(default)] + reset_master_password: bool, + #[serde(default)] + reset_two_factor: bool, } // Upstream reports this is the renamed endpoint instead of `/keys` @@ -2905,12 +2923,43 @@ async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders get_organization_public_key(org_id, headers, conn).await } +// Will allow to reset 2FA too +// https://github.com/bitwarden/clients/blob/web-v2026.4.2/libs/admin-console/src/common/organization-user/models/requests/organization-user-reset-password.request.ts +#[put("/organizations//users//recover-account", data = "")] +async fn put_recover_account( + org_id: OrganizationId, + member_id: MembershipId, + headers: AdminHeaders, + data: Json, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + let req = data.into_inner(); + if req.reset_master_password && !req.reset_two_factor { + recover_account(org_id, member_id, headers, req, conn, nt).await + } else { + err!("Unsupported operation") + } +} + +// Deprecated since `v2026.4.2` #[put("/organizations//users//reset-password", data = "")] async fn put_reset_password( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, - data: Json, + data: Json, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await +} + +async fn recover_account( + org_id: OrganizationId, + member_id: MembershipId, + headers: AdminHeaders, + reset_request: OrganizationUserRecoverAccountRequest, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -2944,8 +2993,6 @@ async fn put_reset_password( err!(format!("Error sending user reset password email: {e:#?}")); } - let reset_request = data.into_inner(); - let mut user = user; user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn) .await?; diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 2a7e06c1..fb3ee48f 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -12,7 +12,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, - auth::{ClientIp, Headers, Host}, + auth::{ClientIp, Headers, Host, SendHeaders}, config::PathType, db::{ DbConn, DbPool, @@ -48,7 +48,9 @@ pub fn routes() -> Vec { post_send, post_send_file, post_access, + post_access_legacy, post_access_file, + post_access_file_legacy, put_send, delete_send, put_remove_password, @@ -78,6 +80,7 @@ pub struct SendData { deletion_date: DateTime, disabled: bool, hide_email: Option, + emails: Option, // Data field name: String, @@ -148,6 +151,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { ); } + if data.emails.is_some() { + err!("Sends with email verification is not supported"); + } + let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc()); send.user_uuid = Some(user_id); send.notes = data.notes; @@ -371,7 +378,7 @@ pub struct SendFileData { } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195 -#[post("/sends//file/", format = "multipart/form-data", data = "")] +#[post("/sends//file/", format = "multipart/form-data", data = "", rank = 2)] async fn post_send_file_v2_data( send_id: SendId, file_id: SendFileId, @@ -441,14 +448,23 @@ async fn post_send_file_v2_data( Ok(()) } +#[post("/sends/access")] +async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult { + let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { + err_code!(SEND_INACCESSIBLE_MSG, 404) + }; + process_access(send, conn, nt).await +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendAccessData { pub password: Option, } +// Legacy since web-2026.6.0 #[post("/sends/access/", data = "")] -async fn post_access( +async fn post_access_legacy( access_id: &str, data: Json, conn: DbConn, @@ -494,6 +510,10 @@ async fn post_access( send.save(&conn).await?; + process_access(send, conn, nt).await +} + +async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult { nt.send_send_update( UpdateType::SyncSendUpdate, &send, @@ -506,8 +526,23 @@ async fn post_access( Ok(Json(send.to_json_access(&conn).await)) } -#[post("/sends//access/file/", data = "")] +#[post("/sends/access/file/", rank = 1)] async fn post_access_file( + file_id: SendFileId, + headers: SendHeaders, + host: Host, + conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { + err_code!(SEND_INACCESSIBLE_MSG, 404) + }; + process_access_file(send, file_id, host, conn, nt).await +} + +// Legacy since web-2026.6.0 +#[post("/sends//access/file/", data = "")] +async fn post_access_file_legacy( send_id: SendId, file_id: SendFileId, data: Json, @@ -551,6 +586,10 @@ async fn post_access_file( send.save(&conn).await?; + process_access_file(send, file_id, host, conn, nt).await +} + +async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult { nt.send_send_update( UpdateType::SyncSendUpdate, &send, @@ -563,7 +602,7 @@ async fn post_access_file( Ok(Json(json!({ "object": "send-fileDownload", "id": file_id, - "url": download_url(&host, &send_id, &file_id).await?, + "url": download_url(&host, &send.uuid, &file_id).await?, }))) } @@ -601,6 +640,10 @@ async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: err!("Send not found", "Send send_id is invalid or does not belong to user") }; + if data.emails.is_some() { + err!("Sends with email verification is not supported"); + } + update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; Ok(Json(send.to_json())) diff --git a/src/api/icons.rs b/src/api/icons.rs index 02a14844..81191e38 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -65,7 +65,7 @@ static CLIENT: LazyLock = LazyLock::new(|| { let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout()); let pool_idle_timeout = Duration::from_secs(10); // Reuse the client between requests - get_reqwest_client_builder() + get_reqwest_client_builder(true) .cookie_provider(Arc::clone(&cookie_store)) .timeout(icon_download_timeout) .pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections diff --git a/src/api/identity.rs b/src/api/identity.rs index a727118c..85116bb6 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -37,8 +37,8 @@ use crate::{ DbConn, models::{ AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, - OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, - UserId, WebauthnCredential, + OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, + TwoFactorType, User, UserId, WebauthnCredential, }, }, error::MapResult, @@ -129,6 +129,19 @@ async fn login( webauthn_login(data, &mut user_id, &conn, &client_header.ip).await } "webauthn" => err!("Passkey login is not allowed"), + "send_access" => { + check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; + check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?; + + let tokens = auth::SendTokens::generate_tokens( + data.send_id.as_ref().unwrap(), + data.password_hash_b64, + &client_header.ip, + &conn, + ) + .await?; + Ok(Json(tokens.to_json())) + } t => err!("Invalid type", t), }; @@ -1327,6 +1340,10 @@ struct ConnectData { device_response: Option, #[field(name = uncased("token"))] token: Option, + + // Needed for send access + send_id: Option, + password_hash_b64: Option, } fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { if value.is_none() { diff --git a/src/auth.rs b/src/auth.rs index be8d1083..aea44ecb 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,3 +1,8 @@ +#[path = "auth/send.rs"] +pub mod send; +pub type SendTokens = send::SendTokens; +pub type SendHeaders = send::SendHeaders; + use std::{ env, net::IpAddr, @@ -516,6 +521,16 @@ pub struct BasicJwtClaims { pub sub: String, } +impl BasicJwtClaims { + pub fn expires_in(&self) -> i64 { + self.exp - Utc::now().timestamp() + } + + pub fn token(&self) -> String { + encode_jwt(&self) + } +} + pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { let time_now = Utc::now(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); diff --git a/src/auth/send.rs b/src/auth/send.rs new file mode 100644 index 00000000..84500b6a --- /dev/null +++ b/src/auth/send.rs @@ -0,0 +1,156 @@ +use chrono::{TimeDelta, Utc}; + +use rocket::request::{FromRequest, Outcome, Request}; + +use crate::{ + api::ApiResult, + auth, + auth::{BasicJwtClaims, ClientIp}, + db::{ + DbConn, + models::{Send, SendId}, + }, + error::{Error, ErrorKind}, +}; + +fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims { + let time_now = Utc::now(); + BasicJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + TimeDelta::try_minutes(2).unwrap()).timestamp(), + iss: auth::JWT_SEND_ISSUER.to_string(), + sub: format!("{send_id}"), + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SendTokens { + pub access_claims: BasicJwtClaims, +} + +impl SendTokens { + pub fn as_send_id(access_id: &str) -> Option { + data_encoding::BASE64URL_NOPAD + .decode(access_id.as_bytes()) + .ok() + .and_then(|uuid_vec| uuid::Uuid::from_slice(&uuid_vec).ok().map(|u| SendId::from(u.to_string()))) + } + + pub fn to_json(&self) -> serde_json::Value { + json!({ + "access_token": self.access_claims.token(), + "expires_in": self.access_claims.expires_in(), + "token_type": "Bearer", + "scope": "api.send.access", + }) + } + + fn expected_error(msg: &str, error_type: &str) -> ApiResult { + let err = json!({ + "kind": "expected_server", + "error": "invalid_request", + "send_access_error_type": error_type, + }); + + Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent()) + } + + fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult { + let err = json!({ + "kind": "expected_server", + "error": "invalid_grant", + "send_access_error_type": error_type, + }); + + Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).with_code(404).with_silent(silent)) + } + + pub async fn generate_tokens( + access_id: &str, + password: Option, + ip: &ClientIp, + conn: &DbConn, + ) -> ApiResult { + let Some(send_id) = Self::as_send_id(access_id) else { + return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false); + }; + + let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else { + return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false); + }; + + if let Some(max_access_count) = send.max_access_count + && send.access_count >= max_access_count + { + return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true); + } + + if let Some(expiration) = send.expiration_date + && Utc::now().naive_utc() >= expiration + { + return Self::invalid_error(&format!("Send {send_id}, expired"), "send_id_invalid", true); + } + + if Utc::now().naive_utc() >= send.deletion_date { + return Self::invalid_error(&format!("Send {send_id}, past deletion"), "send_id_invalid", true); + } + + if send.disabled { + return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true); + } + + if send.password_hash.is_some() { + match password { + Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } + Some(_) => { + return Self::invalid_error( + &format!("Send {send_id}, Invalid password from {}", ip.ip), + "password_hash_b64_invalid", + false, + ); + } + None => return Self::expected_error("Password required", "password_hash_b64_required"), + } + } + + send.access_count += 1; + send.save(conn).await?; + + Ok(Self { + access_claims: generate_send_access_claims(&send_id), + }) + } +} + +pub struct SendHeaders { + pub send_id: SendId, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for SendHeaders { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let headers = request.headers(); + + // Get access_token + let access_token: &str = if let Some(a) = headers.get_one("Authorization") { + if let Some(split) = a.rsplit("Bearer ").next() { + split + } else { + err_handler!("No access token provided") + } + } else { + err_handler!("No access token provided") + }; + + // Check JWT token is valid and get send_id + let Ok(claims) = auth::decode_send(access_token) else { + err_handler!("Invalid claim") + }; + + Outcome::Success(SendHeaders { + send_id: claims.sub.into(), + }) + } +} diff --git a/src/config.rs b/src/config.rs index db1351f3..a8a09f11 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1408,6 +1408,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "ssh-key-vault-item", "pm-2035-passkey-unlock", "pm-25373-windows-biometrics-v2", + "pm-26340-linux-biometrics-v2", // Mobile Team "anon-addy-self-host-alias", "simple-login-self-host-alias", diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 0a2f1a2a..a35bcf8d 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -161,7 +161,7 @@ impl Send { "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), "authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 }, "disabled": self.disabled, - "hideEmail": self.hide_email, + "hideEmail": self.hide_email.unwrap_or(false), "revisionDate": format_date(&self.revision_date), "expirationDate": self.expiration_date.as_ref().map(format_date), diff --git a/src/error.rs b/src/error.rs index d075dfe5..ecbc8199 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,14 +15,14 @@ macro_rules! make_error { #[derive(Debug)] pub struct ErrorEvent { pub event: EventType } - pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option } + pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option, silent: bool } $(impl From<$ty> for Error { fn from(err: $ty) -> Self { Error::from((stringify!($name), err)) } })+ $(impl> From<(S, $ty)> for Error { fn from(val: (S, $ty)) -> Self { - Error { message: val.0.into(), kind: ErrorKind::$name(val.1), code: BAD_REQUEST, event: None } + Error { message: val.0.into(), kind: ErrorKind::$name(val.1), code: BAD_REQUEST, event: None, silent: false } } })+ impl StdError for Error { @@ -172,6 +172,18 @@ impl Error { pub fn message(&self) -> &str { &self.message } + + #[must_use] + pub fn silent(mut self) -> Self { + self.silent = true; + self + } + + #[must_use] + pub fn with_silent(mut self, silent: bool) -> Self { + self.silent = silent; + self + } } pub trait MapResult { @@ -309,9 +321,11 @@ use rocket::{ impl Responder<'_, 'static> for Error { fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { - match self.kind { - ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation - _ => error!(target: "error", "{self:#?}"), + if !self.silent { + match self.kind { + ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation + _ => error!(target: "error", "{self:#?}"), + } } let code = Status::from_code(self.code).unwrap_or(Status::BadRequest); diff --git a/src/http_client.rs b/src/http_client.rs index 232ba7da..205b1cc3 100644 --- a/src/http_client.rs +++ b/src/http_client.rs @@ -18,7 +18,7 @@ use crate::{CONFIG, util::is_global}; pub fn make_http_request(method: reqwest::Method, url: &str) -> Result { static INSTANCE: LazyLock = - LazyLock::new(|| get_reqwest_client_builder().build().expect("Failed to build client")); + LazyLock::new(|| get_reqwest_client_builder(true).build().expect("Failed to build client")); let Ok(url) = url::Url::parse(url) else { err!("Invalid URL"); @@ -32,7 +32,7 @@ pub fn make_http_request(method: reqwest::Method, url: &str) -> Result ClientBuilder { +pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder { let mut headers = header::HeaderMap::new(); headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden")); @@ -55,7 +55,7 @@ pub fn get_reqwest_client_builder() -> ClientBuilder { Client::builder() .default_headers(headers) .redirect(redirect_policy) - .dns_resolver(CustomDnsResolver::instance()) + .dns_resolver(CustomDns::instance(enforce_block)) .timeout(Duration::from_secs(10)) } @@ -210,6 +210,11 @@ impl fmt::Display for CustomHttpClientError { impl std::error::Error for CustomHttpClientError {} +pub struct CustomDns { + enforce_block: bool, + resolver: Arc, +} + #[derive(Debug, Clone)] enum CustomDnsResolver { Default(), @@ -217,12 +222,18 @@ enum CustomDnsResolver { } type BoxError = Box; -impl CustomDnsResolver { - fn instance() -> Arc { +impl CustomDns { + fn instance(enforce_block: bool) -> Self { static INSTANCE: LazyLock> = LazyLock::new(CustomDnsResolver::new); - Arc::clone(&*INSTANCE) - } + CustomDns { + enforce_block, + resolver: Arc::clone(&*INSTANCE), + } + } +} + +impl CustomDnsResolver { fn new() -> Arc { TokioResolver::builder(TokioRuntimeProvider::default()) .and_then(|mut builder| { @@ -239,30 +250,32 @@ impl CustomDnsResolver { } // Note that we get an iterator of addresses, but we only grab the first one for convenience - async fn resolve_domain(&self, name: &str) -> Result, BoxError> { - pre_resolve(name)?; + async fn resolve_domain(&self, name: &str, enforce_block: bool) -> Result, BoxError> { + pre_resolve(name, enforce_block)?; let results: Vec = match self { Self::Default() => tokio::net::lookup_host((name, 0)).await?.collect(), Self::Hickory(r) => r.lookup_ip(name).await?.iter().map(|i| SocketAddr::new(i, 0)).collect(), }; - for addr in &results { - post_resolve(name, addr.ip())?; + if enforce_block { + for addr in &results { + post_resolve(name, addr.ip())?; + } } Ok(results) } } -fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> { +fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientError> { let Ok(host) = get_valid_host(name) else { return Err(CustomHttpClientError::Invalid { domain: name.to_owned(), }); }; - if should_block_host(&host).is_err() { + if enforce_block && should_block_host(&host).is_err() { return Err(CustomHttpClientError::Blocked { domain: name.to_owned(), }); @@ -282,12 +295,13 @@ fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> { } } -impl Resolve for CustomDnsResolver { +impl Resolve for CustomDns { fn resolve(&self, name: Name) -> Resolving { - let this = self.clone(); + let enforce_block = self.enforce_block; + let this = Arc::clone(&self.resolver); Box::pin(async move { let name = name.as_str(); - let results = this.resolve_domain(name).await?; + let results = this.resolve_domain(name, enforce_block).await?; if results.is_empty() { warn!("Unable to resolve {name} to any valid IP address"); } diff --git a/src/sso_client.rs b/src/sso_client.rs index 355fffcb..ff39b0b0 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -1,16 +1,16 @@ -use std::{borrow::Cow, future::Future, pin::Pin, sync::LazyLock, time::Duration}; +use std::{borrow::Cow, collections::HashSet, future::Future, pin::Pin, sync::LazyLock, time::Duration}; use openidconnect::{ - AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthenticationFlow, AuthorizationCode, AuthorizationRequest, - ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields, EndpointNotSet, EndpointSet, - HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce, OAuth2TokenResponse, - PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse, + AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthType, AuthenticationFlow, AuthorizationCode, + AuthorizationRequest, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields, + EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce, + OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse, StandardTokenResponse, core::{ - CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreErrorResponseType, CoreGenderClaim, CoreIdTokenVerifier, - CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, CoreProviderMetadata, - CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, - CoreTokenResponse, CoreTokenType, CoreUserInfoClaims, + CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreClientAuthMethod, CoreErrorResponseType, CoreGenderClaim, + CoreIdTokenVerifier, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, + CoreProviderMetadata, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, + CoreTokenIntrospectionResponse, CoreTokenResponse, CoreTokenType, CoreUserInfoClaims, }, http, url, }; @@ -71,7 +71,7 @@ pub struct OidcHttpClient { impl OidcHttpClient { fn new() -> Result { - get_reqwest_client_builder().redirect(reqwest::redirect::Policy::none()).build().map(|client| Self { + get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map(|client| Self { client, }) } @@ -83,7 +83,10 @@ impl<'c> AsyncHttpClient<'c> for OidcHttpClient { fn call(&'c self, request: HttpRequest) -> Self::Future { Box::pin(async move { - let response = self.client.execute(request.try_into().map_err(Box::new)?).await.map_err(Box::new)?; + let response = self.client.execute(request.try_into().map_err(Box::new)?).await.map_err(|e| { + debug!("Request failed {e:?}"); + Box::new(e) + })?; let mut builder = http::Response::builder().status(response.status()).version(response.version()); @@ -91,7 +94,9 @@ impl<'c> AsyncHttpClient<'c> for OidcHttpClient { builder = builder.header(name, value); } - builder.body(response.bytes().await.map_err(Box::new)?.to_vec()).map_err(HttpClientError::Http) + let body = response.bytes().await.map_err(Box::new)?; + debug!("Response body {}", String::from_utf8_lossy(&body)); + builder.body(body.to_vec()).map_err(HttpClientError::Http) }) } } @@ -114,7 +119,21 @@ impl Client { Ok(metadata) => metadata, }; - let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); + let auth_methods: Option> = provider_metadata + .token_endpoint_auth_methods_supported() + .map(|v| v.iter().map(ToOwned::to_owned).collect()); + + let mut base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); + + if let Some(am) = auth_methods { + if am.contains(&CoreClientAuthMethod::ClientSecretBasic) { + base_client = base_client.set_auth_type(AuthType::BasicAuth); // Default + } else if am.contains(&CoreClientAuthMethod::ClientSecretPost) { + base_client = base_client.set_auth_type(AuthType::RequestBody); + } else { + err!(format!("No supported auth_methods (only basic or request body), advertised: {am:?}")); + } + } let token_uri = if let Some(uri) = base_client.token_uri() { uri.clone()