Merge branch 'main' of https://github.com/dani-garcia/vaultwarden into feat/webauthn_login

This commit is contained in:
Raphaël Roumezin 2026-07-08 13:59:48 +02:00 committed by Raphael Roumezin
commit 89a25c4e12
No known key found for this signature in database
GPG Key ID: 91D7A94FE35B7922
15 changed files with 603 additions and 80 deletions

View File

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

View File

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

View File

@ -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();
});
});

View File

@ -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<KeysData>,
master_password_hash: String,
master_password_hint: Option<String>,
name: Option<String>,
@ -119,6 +116,102 @@ pub struct RegisterData {
org_invite_token: Option<String>,
}
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<String>,
}
#[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<String> {
match password_hint {
@ -177,6 +263,10 @@ pub async fn register(data: Json<RegisterData>, 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<RegisterData>, 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<RegisterData>, 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

View File

@ -96,6 +96,7 @@ pub fn routes() -> Vec<Route> {
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<Value>,
}
#[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/<org_id>/policies/<pol_type>", data = "<data>")]
async fn put_policy(
org_id: OrganizationId,
pol_type: i32,
data: Json<PolicyData>,
data: Json<PutPolicy>,
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/<org_id>/policies/<pol_type>/vnext", data = "<data>")]
async fn put_policy_vnext(
org_id: OrganizationId,
pol_type: i32,
data: Json<PolicyDataVnext>,
data: Json<PutPolicy>,
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/<org_id>/users/<member_id>/recover-account", data = "<data>")]
async fn put_recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
data: Json<OrganizationUserRecoverAccountRequest>,
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/<org_id>/users/<member_id>/reset-password", data = "<data>")]
async fn put_reset_password(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
data: Json<OrganizationUserResetPasswordRequest>,
data: Json<OrganizationUserRecoverAccountRequest>,
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?;

View File

@ -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<rocket::Route> {
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<Utc>,
disabled: bool,
hide_email: Option<bool>,
emails: Option<String>,
// Data field
name: String,
@ -148,6 +151,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
);
}
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/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", 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<String>,
}
// Legacy since web-2026.6.0
#[post("/sends/access/<access_id>", data = "<data>")]
async fn post_access(
async fn post_access_legacy(
access_id: &str,
data: Json<SendAccessData>,
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/<send_id>/access/file/<file_id>", data = "<data>")]
#[post("/sends/access/file/<file_id>", 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/<send_id>/access/file/<file_id>", data = "<data>")]
async fn post_access_file_legacy(
send_id: SendId,
file_id: SendFileId,
data: Json<SendAccessData>,
@ -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<SendData>, 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()))

View File

@ -65,7 +65,7 @@ static CLIENT: LazyLock<Client> = 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

View File

@ -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<String>,
#[field(name = uncased("token"))]
token: Option<String>,
// Needed for send access
send_id: Option<SendId>,
password_hash_b64: Option<String>,
}
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() {

View File

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

156
src/auth/send.rs Normal file
View File

@ -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<SendId> {
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<SendTokens> {
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<SendTokens> {
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<String>,
ip: &ClientIp,
conn: &DbConn,
) -> ApiResult<SendTokens> {
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<Self, Self::Error> {
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(),
})
}
}

View File

@ -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",

View File

@ -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),

View File

@ -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<ErrorEvent> }
pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option<ErrorEvent>, silent: bool }
$(impl From<$ty> for Error {
fn from(err: $ty) -> Self { Error::from((stringify!($name), err)) }
})+
$(impl<S: Into<String>> 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<S> {
@ -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);

View File

@ -18,7 +18,7 @@ use crate::{CONFIG, util::is_global};
pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> {
static INSTANCE: LazyLock<Client> =
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<reqwest::
Ok(INSTANCE.request(method, url))
}
pub fn get_reqwest_client_builder() -> 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<CustomDnsResolver>,
}
#[derive(Debug, Clone)]
enum CustomDnsResolver {
Default(),
@ -217,12 +222,18 @@ enum CustomDnsResolver {
}
type BoxError = Box<dyn std::error::Error + Send + Sync>;
impl CustomDnsResolver {
fn instance() -> Arc<Self> {
impl CustomDns {
fn instance(enforce_block: bool) -> Self {
static INSTANCE: LazyLock<Arc<CustomDnsResolver>> = LazyLock::new(CustomDnsResolver::new);
Arc::clone(&*INSTANCE)
}
CustomDns {
enforce_block,
resolver: Arc::clone(&*INSTANCE),
}
}
}
impl CustomDnsResolver {
fn new() -> Arc<Self> {
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<Vec<SocketAddr>, BoxError> {
pre_resolve(name)?;
async fn resolve_domain(&self, name: &str, enforce_block: bool) -> Result<Vec<SocketAddr>, BoxError> {
pre_resolve(name, enforce_block)?;
let results: Vec<SocketAddr> = 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");
}

View File

@ -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<Self, reqwest::Error> {
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<HashSet<CoreClientAuthMethod>> = 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()