Browse Source

Merge 8c551de2ab into 58a980f850

pull/7861/merge
ICantThinkOfAName-tech 5 days ago
committed by GitHub
parent
commit
4f08cccc14
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      migrations/mysql/2026-10-16-120000_add_send_email_verification/down.sql
  2. 15
      migrations/mysql/2026-10-16-120000_add_send_email_verification/up.sql
  3. 3
      migrations/postgresql/2026-10-16-120000_add_send_email_verification/down.sql
  4. 15
      migrations/postgresql/2026-10-16-120000_add_send_email_verification/up.sql
  5. 3
      migrations/sqlite/2026-10-16-120000_add_send_email_verification/down.sql
  6. 15
      migrations/sqlite/2026-10-16-120000_add_send_email_verification/up.sql
  7. 15
      src/api/core/ciphers.rs
  8. 11
      src/api/core/mod.rs
  9. 379
      src/api/core/sends.rs
  10. 4
      src/api/identity.rs
  11. 89
      src/auth/send.rs
  12. 4
      src/config.rs
  13. 2
      src/db/models/mod.rs
  14. 159
      src/db/models/send.rs
  15. 15
      src/db/schema.rs
  16. 13
      src/mail.rs
  17. 6
      src/static/templates/email/send_access_otp.hbs
  18. 16
      src/static/templates/email/send_access_otp.html.hbs

3
migrations/mysql/2026-10-16-120000_add_send_email_verification/down.sql

@ -0,0 +1,3 @@
DROP TABLE send_access_otps;
ALTER TABLE sends DROP COLUMN emails;
ALTER TABLE sends DROP COLUMN auth_type;

15
migrations/mysql/2026-10-16-120000_add_send_email_verification/up.sql

@ -0,0 +1,15 @@
ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2;
UPDATE sends SET auth_type = 1 WHERE password_hash IS NOT NULL;
ALTER TABLE sends ADD COLUMN emails TEXT;
CREATE TABLE send_access_otps (
send_uuid CHAR(36) NOT NULL,
email VARCHAR(255) NOT NULL,
code TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
creation_date DATETIME NOT NULL,
expiration_date DATETIME NOT NULL,
PRIMARY KEY (send_uuid, email),
FOREIGN KEY (send_uuid) REFERENCES sends (uuid) ON DELETE CASCADE
);

3
migrations/postgresql/2026-10-16-120000_add_send_email_verification/down.sql

@ -0,0 +1,3 @@
DROP TABLE send_access_otps;
ALTER TABLE sends DROP COLUMN emails;
ALTER TABLE sends DROP COLUMN auth_type;

15
migrations/postgresql/2026-10-16-120000_add_send_email_verification/up.sql

@ -0,0 +1,15 @@
ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2;
UPDATE sends SET auth_type = 1 WHERE password_hash IS NOT NULL;
ALTER TABLE sends ADD COLUMN emails TEXT;
CREATE TABLE send_access_otps (
send_uuid CHAR(36) NOT NULL,
email VARCHAR(255) NOT NULL,
code TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
creation_date TIMESTAMP NOT NULL,
expiration_date TIMESTAMP NOT NULL,
PRIMARY KEY (send_uuid, email),
FOREIGN KEY (send_uuid) REFERENCES sends (uuid) ON DELETE CASCADE
);

3
migrations/sqlite/2026-10-16-120000_add_send_email_verification/down.sql

@ -0,0 +1,3 @@
DROP TABLE send_access_otps;
ALTER TABLE sends DROP COLUMN emails;
ALTER TABLE sends DROP COLUMN auth_type;

15
migrations/sqlite/2026-10-16-120000_add_send_email_verification/up.sql

@ -0,0 +1,15 @@
ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2;
UPDATE sends SET auth_type = 1 WHERE password_hash IS NOT NULL;
ALTER TABLE sends ADD COLUMN emails TEXT;
CREATE TABLE send_access_otps (
send_uuid TEXT NOT NULL,
email TEXT NOT NULL,
code TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
creation_date DATETIME NOT NULL,
expiration_date DATETIME NOT NULL,
PRIMARY KEY (send_uuid, email),
FOREIGN KEY (send_uuid) REFERENCES sends (uuid) ON DELETE CASCADE
);

15
src/api/core/ciphers.rs

@ -29,7 +29,10 @@ use crate::{
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
};
use super::{folders::FolderData, sends::SIZE_525_MB};
use super::{
folders::FolderData,
sends::{SIZE_525_MB, send_visible_to},
};
pub fn routes() -> Vec<Route> {
// Note that many routes have an `admin` variant; this seems to be
@ -127,7 +130,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
let mut ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn).await;
// Filter out SSH keys if the client version is less than 2024.12.0
let show_ssh_keys = if let Some(client_version) = client_version {
let show_ssh_keys = if let Some(client_version) = &client_version {
let ver_match = semver::VersionReq::parse(">=2024.12.0").unwrap();
ver_match.matches(&client_version.0)
} else {
@ -156,8 +159,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
let folders_json: Vec<Value> =
Folder::find_by_user(&headers.user.uuid, &conn).await.iter().map(Folder::to_json).collect();
let sends_json: Vec<Value> =
Send::find_by_user(&headers.user.uuid, &conn).await.iter().map(Send::to_json).collect();
let sends_json: Vec<Value> = Send::find_by_user(&headers.user.uuid, &conn)
.await
.iter()
.filter(|s| send_visible_to(s, &headers.device, client_version.as_ref()))
.map(Send::to_json)
.collect();
let policies_json: Vec<Value> =
OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect();

11
src/api/core/mod.rs

@ -13,7 +13,7 @@ pub use accounts::purge_auth_requests;
pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers};
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
pub use events::{event_cleanup_job, log_event, log_user_event};
pub use sends::purge_sends;
pub use sends::{purge_sends, send_visible};
use reqwest::Method;
use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
@ -21,7 +21,7 @@ use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
use crate::{
CONFIG,
api::{EmptyResult, JsonResult, Notify, UpdateType},
auth::Headers,
auth::{ClientVersion, Headers},
db::{
DbConn,
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
@ -208,7 +208,8 @@ fn get_api_webauthn(_headers: Headers) -> Json<Value> {
}
#[get("/config")]
fn config() -> Json<Value> {
#[expect(clippy::needless_pass_by_value, reason = "Rocket request guards are passed by value")]
fn config(client_version: Option<ClientVersion>) -> Json<Value> {
let domain = CONFIG.domain();
// Official available feature flags can be found here:
// Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135
@ -220,6 +221,10 @@ fn config() -> Json<Value> {
&FeatureFlagFilter::ValidOnly,
);
feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
// Older clients with only part of the item sharing feature could create Item Sends they can't handle
if !sends::client_version_supports_item_sends(client_version.as_ref()) {
feature_states.remove(sends::ITEM_SENDS_FEATURE_FLAG);
}
Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns

379
src/api/core/sends.rs

@ -1,7 +1,7 @@
use std::{path::Path, sync::LazyLock, time::Duration};
use chrono::{DateTime, TimeDelta, Utc};
use num_traits::ToPrimitive;
use num_traits::{FromPrimitive, ToPrimitive};
use rocket::{
form::Form,
fs::{NamedFile, TempFile},
@ -12,13 +12,16 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{Headers, Host, SendHeaders},
auth::{ClientVersion, Headers, Host, SendHeaders},
config::PathType,
db::{
DbConn, DbPool,
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
models::{
Cipher, CipherId, Device, OrgPolicy, OrgPolicyType, Send, SendAuthType, SendFileId, SendId, SendType,
UserId,
},
util::{NumberOrString, save_temp_file},
},
util::{FeatureFlagFilter, NumberOrString, parse_experimental_client_feature_flags, save_temp_file},
};
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
@ -77,6 +80,7 @@ pub struct SendData {
deletion_date: DateTime<Utc>,
disabled: bool,
hide_email: Option<bool>,
auth_type: Option<i32>,
emails: Option<String>,
// Data field
@ -84,6 +88,7 @@ pub struct SendData {
notes: Option<String>,
text: Option<Value>,
file: Option<Value>,
data: Option<Value>,
file_length: Option<NumberOrString>,
// Used for key rotations
@ -131,6 +136,8 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
data.text
} else if data.r#type == SendType::File as i32 {
data.file
} else if data.r#type == SendType::Item as i32 {
Some(serde_json::to_value(parse_item_data(data.data)?)?)
} else {
err!("Invalid Send type")
};
@ -148,10 +155,6 @@ 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;
@ -164,15 +167,212 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
send.hide_email = data.hide_email;
send.atype = data.r#type;
send.set_password(data.password.as_deref());
apply_send_auth(&mut send, data.auth_type, data.emails.as_deref(), data.password)?;
enforce_mail_enabled_for_email_auth(&send)?;
enforce_email_auth_for_item_send(&send)?;
Ok(send)
}
pub const ITEM_SENDS_FEATURE_FLAG: &str = "pm-34203-temporary-item-sharing";
// Clients can only decrypt (and rotate the keys of) Item Sends through the SDK
const SDK_SENDS_FEATURE_FLAG: &str = "pm-30110-sdk-sends-api";
/// Item Sends (sharing a vault item through a temporary link) are opt-in through the client feature flags,
/// both are needed for clients to handle them.
pub fn item_sends_enabled() -> bool {
let flags = parse_experimental_client_feature_flags(
&CONFIG.experimental_client_feature_flags(),
&FeatureFlagFilter::ValidOnly,
);
flags.contains_key(ITEM_SENDS_FEATURE_FLAG) && flags.contains_key(SDK_SENDS_FEATURE_FLAG)
}
/// Whether a Send can be accessed by a recipient, Item Sends are hidden while the feature is disabled.
pub fn send_visible(send: &Send) -> bool {
send.atype != SendType::Item as i32 || item_sends_enabled()
}
/// Clients able to handle Item Sends. The mobile apps and the CLI don't know the type (an unknown Send type
/// breaks the whole Android sync), the other clients support it since 2026.10.0.
pub fn client_supports_item_sends(device: &Device, client_version: Option<&ClientVersion>) -> bool {
!device.is_mobile() && !device.is_cli() && client_version_supports_item_sends(client_version)
}
pub fn client_version_supports_item_sends(client_version: Option<&ClientVersion>) -> bool {
static MIN_VERSION: LazyLock<semver::VersionReq> =
LazyLock::new(|| semver::VersionReq::parse(">=2026.10.0").unwrap());
client_version.is_some_and(|v| MIN_VERSION.matches(&v.0))
}
/// Whether a Send can be shown to its owner on the requesting client.
pub fn send_visible_to(send: &Send, device: &Device, client_version: Option<&ClientVersion>) -> bool {
send.atype != SendType::Item as i32 || (item_sends_enabled() && client_supports_item_sends(device, client_version))
}
/// Data of an Item Send. The vault item is encrypted client side into a single opaque blob, the item id
/// is kept in clear so the server can check that the sender is allowed to share it.
///
/// Upstream: https://github.com/bitwarden/server/blob/main/src/Core/Tools/Models/Data/SendItemData.cs
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct SendItemData {
encryption_version: i32,
data: String,
metadata: SendItemMetadata,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct SendItemMetadata {
item_id: CipherId,
}
impl SendItemData {
const ENCRYPTION_VERSION: i32 = 1;
const MAX_DATA_LENGTH: usize = 500_000;
}
fn parse_item_data(data: Option<Value>) -> ApiResult<SendItemData> {
if !item_sends_enabled() {
err!("Item Sends are not enabled on this server")
}
let Some(data) = data else {
err!("Send data not provided")
};
let Ok(item) = serde_json::from_value::<SendItemData>(data) else {
err!("Invalid Item Send data")
};
if item.encryption_version != SendItemData::ENCRYPTION_VERSION {
err!("Unsupported Item Send encryption version")
}
if item.data.is_empty() || item.data.len() > SendItemData::MAX_DATA_LENGTH {
err!("Invalid Item Send data")
}
Ok(item)
}
fn stored_item_id(send: &Send) -> Option<CipherId> {
serde_json::from_str::<SendItemData>(&send.data).ok().map(|d| d.metadata.item_id)
}
fn enforce_email_auth_for_item_send(send: &Send) -> EmptyResult {
if send.atype == SendType::Item as i32 && send.auth_type != SendAuthType::Email as i32 {
err!("Item Sends require email verification")
}
Ok(())
}
/// A user can only share an item they own, or an organization item they can edit and see the password of.
/// Deleted, archived and SSH key items can't be shared.
///
/// Upstream: https://github.com/bitwarden/server/blob/main/src/Core/Tools/SendFeatures/Services/SendValidationService.cs
async fn enforce_item_shareable(item_id: &CipherId, user_id: &UserId, conn: &DbConn) -> EmptyResult {
const SSH_KEY_TYPE: i32 = 5;
let Some(cipher) = Cipher::find_by_uuid(item_id, conn).await else {
err!("Item not found.")
};
let can_share = if cipher.user_uuid.as_ref() == Some(user_id) {
true
} else if cipher.organization_uuid.is_some() {
// (read_only, hide_passwords, manage)
matches!(cipher.get_access_restrictions(user_id, None, conn).await, Some((false, false, _)))
} else {
false
};
if !can_share
|| cipher.deleted_at.is_some()
|| cipher.atype == SSH_KEY_TYPE
|| cipher.get_archived_at(user_id, conn).await.is_some()
{
err!("Item not found.")
}
Ok(())
}
/// Splits a comma separated list of emails, trimming and lowercasing each entry and dropping empty ones.
/// Returns `None` when no address is left.
fn normalize_emails(emails: Option<&str>) -> Option<String> {
let emails: Vec<String> = emails?.split(',').map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()).collect();
(!emails.is_empty()).then(|| emails.join(","))
}
/// Applies the requested access control to a Send, used for both creation and updates.
///
/// When `authType` is missing (older clients) it is inferred from the other fields, like upstream does.
/// The stored `auth_type` is always derived from the resulting state, so a Send never claims a
/// protection it doesn't actually enforce.
///
/// Upstream: https://github.com/bitwarden/server/blob/main/src/Api/Tools/Models/Request/SendRequestModel.cs
fn apply_send_auth(
send: &mut Send,
auth_type: Option<i32>,
emails: Option<&str>,
password: Option<String>,
) -> EmptyResult {
let auth_type = match auth_type.map(SendAuthType::from_i32) {
Some(None) => err!("Invalid authType"),
Some(t) => t,
None => None,
};
let emails = normalize_emails(emails);
let password = password.filter(|p| !p.is_empty());
match auth_type {
Some(SendAuthType::Email) => {
let Some(emails) = emails else {
err!("Email verification requires at least one email address")
};
send.emails = Some(emails);
send.set_password(None);
}
Some(SendAuthType::Password) => {
send.emails = None;
// Editing a password protected Send doesn't resend the password, keep the existing one
if let Some(password) = password {
send.set_password(Some(&password));
} else if send.password_hash.is_none() {
err!("A password is required")
}
}
Some(SendAuthType::None) => {
send.emails = None;
send.set_password(None);
}
// Unlike upstream, an email protected Send is kept as is when no emails are provided, clients
// without `authType` don't know about emails so they can't have intentionally removed them.
None => {
if emails.is_some() {
send.emails = emails;
send.set_password(None);
} else if let Some(password) = password {
send.emails = None;
send.set_password(Some(&password));
}
}
}
send.auth_type = send.effective_auth_type() as i32;
Ok(())
}
fn enforce_mail_enabled_for_email_auth(send: &Send) -> EmptyResult {
if send.emails.is_some() && !CONFIG.mail_enabled() {
err!("Email verification for Sends requires email to be enabled on this server")
}
Ok(())
}
#[get("/sends")]
async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
async fn get_sends(headers: Headers, client_version: Option<ClientVersion>, conn: DbConn) -> Json<Value> {
let sends = Send::find_by_user(&headers.user.uuid, &conn);
let sends_json: Vec<Value> = sends.await.iter().map(Send::to_json).collect();
let sends_json: Vec<Value> = sends
.await
.iter()
.filter(|s| send_visible_to(s, &headers.device, client_version.as_ref()))
.map(Send::to_json)
.collect();
Json(json!({
"data": sends_json,
@ -182,8 +382,15 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
}
#[get("/sends/<send_id>")]
async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult {
if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
async fn get_send(
send_id: SendId,
headers: Headers,
client_version: Option<ClientVersion>,
conn: DbConn,
) -> JsonResult {
if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await
&& send_visible_to(&send, &headers.device, client_version.as_ref())
{
Ok(Json(send.to_json()))
} else {
err!("Send not found", "Invalid send uuid or does not belong to user")
@ -201,7 +408,10 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
err!("File sends should use /api/sends/file/v2")
}
let mut send = create_send(data, headers.user.uuid)?;
let mut send = create_send(data, headers.user.uuid.clone())?;
if let Some(item_id) = stored_item_id(&send) {
enforce_item_shareable(&item_id, &headers.user.uuid, &conn).await?;
}
send.save(&conn).await?;
nt.send_send_update(
UpdateType::SyncSendCreate,
@ -368,11 +578,13 @@ async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> Json
let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
if !send.is_accessible() || !send_visible(&send) {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
// Files are incremented during the download
if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? {
if (send.atype == SendType::Text as i32 || send.atype == SendType::Item as i32)
&& !send.register_access(&conn).await?
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access(send, conn, nt).await
@ -473,10 +685,6 @@ 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()))
@ -514,6 +722,13 @@ async fn update_send_from_data(
err!("Send data not provided");
};
send.data = data_str;
} else if data.r#type == SendType::Item as i32 {
let item = parse_item_data(data.data)?;
// Only check access to the item when it changes
if stored_item_id(send).as_ref() != Some(&item.metadata.item_id) {
enforce_item_shareable(&item.metadata.item_id, &headers.user.uuid, conn).await?;
}
send.data = serde_json::to_string(&item)?;
}
send.name = data.name;
@ -528,10 +743,9 @@ async fn update_send_from_data(
send.hide_email = data.hide_email;
send.disabled = data.disabled;
// Only change the value if it's present
if let Some(password) = data.password {
send.set_password(Some(&password));
}
apply_send_auth(send, data.auth_type, data.emails.as_deref(), data.password)?;
enforce_mail_enabled_for_email_auth(send)?;
enforce_email_auth_for_item_send(send)?;
send.save(conn).await?;
if ut != UpdateType::None {
@ -568,6 +782,7 @@ async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt
};
send.set_password(None);
send.auth_type = send.effective_auth_type() as i32;
send.save(&conn).await?;
nt.send_send_update(
UpdateType::SyncSendUpdate,
@ -580,3 +795,119 @@ async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt
Ok(Json(send.to_json()))
}
#[cfg(test)]
mod tests {
use super::*;
fn new_send() -> Send {
Send::new(
SendType::Text as i32,
String::from("name"),
String::from("{}"),
String::from("key"),
Utc::now().naive_utc(),
)
}
const EMAIL: i32 = SendAuthType::Email as i32;
const PASSWORD: i32 = SendAuthType::Password as i32;
const NONE: i32 = SendAuthType::None as i32;
#[test]
fn test_normalize_emails() {
assert_eq!(normalize_emails(Some(" A@x.com, ,b@X.com ,")).as_deref(), Some("a@x.com,b@x.com"));
assert_eq!(normalize_emails(Some(" , ")), None);
assert_eq!(normalize_emails(Some("")), None);
assert_eq!(normalize_emails(None), None);
}
#[test]
fn test_email_auth_requires_an_email() {
let mut send = new_send();
assert!(apply_send_auth(&mut send, Some(EMAIL), Some(" , "), None).is_err());
assert!(apply_send_auth(&mut send, Some(EMAIL), None, None).is_err());
}
#[test]
fn test_email_auth_clears_password() {
let mut send = new_send();
apply_send_auth(&mut send, Some(PASSWORD), None, Some(String::from("secret"))).unwrap();
apply_send_auth(&mut send, Some(EMAIL), Some("A@x.com"), Some(String::from("secret"))).unwrap();
assert_eq!(send.auth_type, EMAIL);
assert_eq!(send.emails.as_deref(), Some("a@x.com"));
assert!(send.password_hash.is_none());
}
#[test]
fn test_password_auth() {
let mut send = new_send();
assert!(apply_send_auth(&mut send, Some(PASSWORD), None, None).is_err());
apply_send_auth(&mut send, Some(PASSWORD), Some("a@x.com"), Some(String::from("secret"))).unwrap();
assert_eq!(send.auth_type, PASSWORD);
assert!(send.emails.is_none());
// Edits don't resend the password, the existing one is kept
apply_send_auth(&mut send, Some(PASSWORD), None, None).unwrap();
assert_eq!(send.auth_type, PASSWORD);
assert!(send.check_password("secret"));
}
#[test]
fn test_none_auth_clears_everything() {
let mut send = new_send();
apply_send_auth(&mut send, Some(EMAIL), Some("a@x.com"), None).unwrap();
apply_send_auth(&mut send, Some(NONE), Some("a@x.com"), Some(String::from("secret"))).unwrap();
assert_eq!(send.auth_type, NONE);
assert!(send.emails.is_none());
assert!(send.password_hash.is_none());
}
#[test]
fn test_missing_auth_type_is_inferred() {
let mut send = new_send();
apply_send_auth(&mut send, None, None, Some(String::from("secret"))).unwrap();
assert_eq!(send.auth_type, PASSWORD);
apply_send_auth(&mut send, None, Some("a@x.com"), None).unwrap();
assert_eq!(send.auth_type, EMAIL);
assert!(send.password_hash.is_none());
// Clients without authType don't know about emails, an email protected Send stays protected
apply_send_auth(&mut send, None, None, None).unwrap();
assert_eq!(send.auth_type, EMAIL);
assert_eq!(send.emails.as_deref(), Some("a@x.com"));
}
#[test]
fn test_item_send_data_roundtrip() {
let mut send = new_send();
send.atype = SendType::Item as i32;
send.data = String::from(r#"{"encryptionVersion":1,"data":"blob","metadata":{"itemId":"item-id"}}"#);
assert_eq!(stored_item_id(&send).as_deref().map(String::as_str), Some("item-id"));
// Other Send types don't have an item
assert!(stored_item_id(&new_send()).is_none());
}
#[test]
fn test_item_send_requires_email_auth() {
let mut send = new_send();
send.atype = SendType::Item as i32;
apply_send_auth(&mut send, Some(PASSWORD), None, Some(String::from("secret"))).unwrap();
assert!(enforce_email_auth_for_item_send(&send).is_err());
apply_send_auth(&mut send, Some(EMAIL), Some("a@x.com"), None).unwrap();
assert!(enforce_email_auth_for_item_send(&send).is_ok());
// Text Sends aren't affected
assert!(enforce_email_auth_for_item_send(&new_send()).is_ok());
}
#[test]
fn test_invalid_auth_type() {
let mut send = new_send();
assert!(apply_send_auth(&mut send, Some(42), None, None).is_err());
}
}

4
src/api/identity.rs

@ -105,6 +105,8 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
let tokens = auth::SendTokens::generate_tokens(
data.send_id.as_ref().unwrap(),
data.password_hash_b64,
data.email,
data.otp,
&client_header.ip,
&conn,
)
@ -1165,6 +1167,8 @@ struct ConnectData {
// Needed for send access
send_id: Option<SendId>,
password_hash_b64: Option<String>,
email: Option<String>,
otp: Option<String>,
}
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() {

89
src/auth/send.rs

@ -1,16 +1,20 @@
use chrono::{TimeDelta, Utc};
use num_traits::FromPrimitive;
use rocket::request::{FromRequest, Outcome, Request};
use crate::{
CONFIG,
api::ApiResult,
auth,
auth::{BasicJwtClaims, ClientIp},
crypto,
db::{
DbConn,
models::{Send, SendId},
models::{Send, SendAccessOtp, SendAuthType, SendId},
},
error::{Error, ErrorKind},
mail,
};
fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims {
@ -45,7 +49,7 @@ impl SendTokens {
})
}
fn expected_error(msg: &str, error_type: &str) -> ApiResult<SendTokens> {
fn expected_error<T>(msg: &str, error_type: &str) -> ApiResult<T> {
let err = json!({
"kind": "expected_server",
"error": "invalid_request",
@ -55,7 +59,7 @@ impl SendTokens {
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent())
}
fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult<SendTokens> {
fn invalid_error<T>(msg: &str, error_type: &str, silent: bool) -> ApiResult<T> {
let err = json!({
"kind": "expected_server",
"error": "invalid_grant",
@ -68,6 +72,8 @@ impl SendTokens {
pub async fn generate_tokens(
access_id: &str,
password: Option<String>,
email: Option<String>,
otp: Option<String>,
ip: &ClientIp,
conn: &DbConn,
) -> ApiResult<SendTokens> {
@ -85,12 +91,14 @@ impl SendTokens {
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
if !send.is_accessible() {
if !send.is_accessible() || !crate::api::core::send_visible(&send) {
return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true);
}
if send.password_hash.is_some() {
match password {
// Rely on the stored auth type and fail closed if the Send doesn't hold what it requires
match SendAuthType::from_i32(send.auth_type) {
Some(SendAuthType::Email) => Self::verify_email_otp(&send, email, otp, conn).await?,
Some(SendAuthType::Password) => match password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => {
return Self::invalid_error(
@ -100,6 +108,14 @@ impl SendTokens {
);
}
None => return Self::expected_error("Password required", "password_hash_b64_required"),
},
Some(SendAuthType::None) if send.password_hash.is_none() && send.emails.is_none() => {}
_ => {
return Self::invalid_error(
&format!("Send {send_id}, inconsistent access settings"),
"send_id_invalid",
false,
);
}
}
@ -109,6 +125,67 @@ impl SendTokens {
}
}
impl SendTokens {
/// Email verification: the first request (without `otp`) emails a code to the recipient, the
/// second one exchanges it for an access token.
///
/// Unlisted emails get the same response as listed ones and no email is sent, so the list of
/// recipients can't be enumerated.
///
/// Upstream: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs
async fn verify_email_otp(send: &Send, email: Option<String>, otp: Option<String>, conn: &DbConn) -> ApiResult<()> {
let Some(allowed_emails) = send.emails.as_deref() else {
return Self::invalid_error(&format!("Send {}, no emails to verify", send.uuid), "send_id_invalid", false);
};
if !CONFIG.mail_enabled() {
return Self::invalid_error(
&format!("Send {}, email verification without mail enabled", send.uuid),
"send_id_invalid",
false,
);
}
let Some(email) = email.map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()) else {
return Self::expected_error("Email required", "email_required");
};
if !allowed_emails.split(',').any(|e| e == email) {
return Self::expected_error("Email and OTP required", "email_and_otp_required");
}
let stored = SendAccessOtp::find(&send.uuid, &email, conn).await;
let Some(code) = otp.map(|o| o.trim().to_owned()).filter(|o| !o.is_empty()) else {
if stored.as_ref().is_none_or(SendAccessOtp::can_resend) {
let code = crypto::generate_email_token(CONFIG.email_token_size());
SendAccessOtp::new(send.uuid.clone(), email.clone(), code.clone()).save(conn).await?;
// Mail in the background, so neither the response time nor a mail failure tells listed
// and unlisted emails apart. On failure the recipient can request a new code after the cooldown.
tokio::spawn(async move {
if let Err(e) = mail::send_send_access_otp(&email, &code).await {
error!("Failed to send Send verification code: {e:#?}");
}
});
}
return Self::expected_error("Email and OTP required", "email_and_otp_required");
};
let Some(stored) = stored else {
return Self::expected_error("Email and OTP required", "email_and_otp_required");
};
if stored.is_expired() || stored.attempts_exhausted() {
stored.delete(conn).await?;
return Self::expected_error("Email and OTP required", "email_and_otp_required");
}
if !crypto::ct_eq(&code, &stored.code) {
stored.increment_attempts(conn).await?;
return Self::expected_error("Email and OTP required", "email_and_otp_required");
}
// Codes are single use
stored.delete(conn).await?;
Ok(())
}
}
pub struct SendHeaders {
pub send_id: SendId,
}

4
src/config.rs

@ -1439,6 +1439,9 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"pm-34171-card-scanner",
// Platform Team
"pm-30529-webauthn-related-origins",
// Tools Team
"pm-30110-sdk-sends-api",
"pm-34203-temporary-item-sharing",
// Vault Team
"pm-32009-new-item-types",
];
@ -1736,6 +1739,7 @@ where
reg!("email/invite_confirmed", ".html");
reg!("email/new_device_logged_in", ".html");
reg!("email/protected_action", ".html");
reg!("email/send_access_otp", ".html");
reg!("email/pw_hint_none", ".html");
reg!("email/pw_hint_some", ".html");
reg!("email/recover_twofactor", ".html");

2
src/db/models/mod.rs

@ -34,7 +34,7 @@ pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
OrganizationId,
};
pub use self::send::{Send, SendFileId, SendId, SendType};
pub use self::send::{Send, SendAccessOtp, SendAuthType, SendFileId, SendId, SendType};
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext;

159
src/db/models/send.rs

@ -1,6 +1,6 @@
use std::path::Path;
use chrono::{NaiveDateTime, Utc};
use chrono::{NaiveDateTime, TimeDelta, Utc};
use data_encoding::BASE64URL_NOPAD;
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
@ -12,7 +12,10 @@ use crate::{
CONFIG,
api::EmptyResult,
config::PathType,
db::{DbConn, schema::sends},
db::{
DbConn,
schema::{send_access_otps, sends},
},
error::MapResult,
util::{LowerCase, NumberOrString, format_date},
};
@ -49,18 +52,21 @@ pub struct Send {
pub disabled: bool,
pub hide_email: Option<bool>,
pub auth_type: i32,
pub emails: Option<String>,
}
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
pub enum SendType {
Text = 0,
File = 1,
Item = 2,
}
enum SendAuthType {
#[allow(dead_code)]
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
pub enum SendAuthType {
// Send requires email OTP verification
Email = 0, // Not yet supported by Vaultwarden
Email = 0,
// Send requires a password
Password = 1,
// Send requires no auth
@ -96,6 +102,8 @@ impl Send {
disabled: false,
hide_email: None,
auth_type: SendAuthType::None as i32,
emails: None,
}
}
@ -115,6 +123,17 @@ impl Send {
}
}
/// The protection actually enforced, given the stored emails and password.
pub fn effective_auth_type(&self) -> SendAuthType {
if self.emails.is_some() {
SendAuthType::Email
} else if self.password_hash.is_some() {
SendAuthType::Password
} else {
SendAuthType::None
}
}
pub fn check_password(&self, password: &str) -> bool {
match (&self.password_hash, &self.password_salt, self.password_iter) {
(Some(hash), Some(salt), Some(iter)) => {
@ -161,12 +180,14 @@ impl Send {
"notes": self.notes,
"text": if self.atype == SendType::Text as i32 { Some(&data) } else { None },
"file": if self.atype == SendType::File as i32 { Some(&data) } else { None },
"data": if self.atype == SendType::Item as i32 { Some(&data) } else { None },
"key": self.akey,
"maxAccessCount": self.max_access_count,
"accessCount": self.access_count,
"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 },
"authType": self.auth_type,
"emails": self.emails,
"disabled": self.disabled,
"hideEmail": self.hide_email.unwrap_or(false),
@ -192,6 +213,7 @@ impl Send {
"name": self.name,
"text": if self.atype == SendType::Text as i32 { Some(&data) } else { None },
"file": if self.atype == SendType::File as i32 { Some(&data) } else { None },
"data": if self.atype == SendType::Item as i32 { Some(&data) } else { None },
"expirationDate": self.expiration_date.as_ref().map(format_date),
"creatorIdentifier": self.creator_identifier(conn).await,
@ -282,17 +304,20 @@ impl Send {
operator.delete_with(&self.uuid).recursive(true).await.ok();
}
SendAccessOtp::delete_all_by_send(&self.uuid, conn).await?;
conn.run(move |conn| {
diesel::delete(sends::table.filter(sends::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting send")
})
.await
}
/// Purge all sends that are past their deletion date.
/// Purge all sends that are past their deletion date, and expired access codes.
pub async fn purge(conn: &DbConn) {
for send in Self::find_by_past_deletion_date(conn).await {
send.delete(conn).await.ok();
}
SendAccessOtp::delete_expired(conn).await.ok();
}
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
@ -361,6 +386,126 @@ impl Send {
}
}
/// A one-time code emailed to a recipient of an email verified Send.
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = send_access_otps)]
#[diesel(primary_key(send_uuid, email))]
pub struct SendAccessOtp {
pub send_uuid: SendId,
pub email: String,
pub code: String,
pub attempts: i32,
pub creation_date: NaiveDateTime,
pub expiration_date: NaiveDateTime,
}
impl SendAccessOtp {
/// Minimum time before a new code can be requested, so the endpoint can't be used to spam a
/// recipient or keep invalidating the code they are about to type.
const RESEND_COOLDOWN_SECONDS: i64 = 60;
pub fn new(send_uuid: SendId, email: String, code: String) -> Self {
let now = Utc::now().naive_utc();
Self {
send_uuid,
email,
code,
attempts: 0,
creation_date: now,
// Same settings as the email 2FA codes
expiration_date: now + TimeDelta::seconds(CONFIG.email_expiration_time().cast_signed()),
}
}
/// Wrong guesses allowed before a code is discarded.
pub fn attempts_exhausted(&self) -> bool {
u64::try_from(self.attempts).unwrap_or(u64::MAX) >= CONFIG.email_attempts_limit()
}
pub fn is_expired(&self) -> bool {
Utc::now().naive_utc() >= self.expiration_date
}
pub fn can_resend(&self) -> bool {
self.is_expired()
|| Utc::now().naive_utc() >= self.creation_date + TimeDelta::seconds(Self::RESEND_COOLDOWN_SECONDS)
}
/// Stores the code, replacing any previous one for the same Send and email.
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
conn.transaction(|conn| {
diesel::delete(
send_access_otps::table
.filter(send_access_otps::send_uuid.eq(&self.send_uuid))
.filter(send_access_otps::email.eq(&self.email)),
)
.execute(conn)?;
diesel::insert_into(send_access_otps::table).values(self).execute(conn)
})
.map_res("Error saving send access otp")
})
.await
}
pub async fn increment_attempts(&self, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::update(
send_access_otps::table
.filter(send_access_otps::send_uuid.eq(&self.send_uuid))
.filter(send_access_otps::email.eq(&self.email)),
)
.set(send_access_otps::attempts.eq(send_access_otps::attempts + 1))
.execute(conn)
.map_res("Error updating send access otp")
})
.await
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(
send_access_otps::table
.filter(send_access_otps::send_uuid.eq(&self.send_uuid))
.filter(send_access_otps::email.eq(&self.email)),
)
.execute(conn)
.map_res("Error deleting send access otp")
})
.await
}
pub async fn delete_all_by_send(send_uuid: &SendId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(send_access_otps::table.filter(send_access_otps::send_uuid.eq(send_uuid)))
.execute(conn)
.map_res("Error deleting send access otps")
})
.await
}
pub async fn delete_expired(conn: &DbConn) -> EmptyResult {
let now = Utc::now().naive_utc();
conn.run(move |conn| {
diesel::delete(send_access_otps::table.filter(send_access_otps::expiration_date.le(now)))
.execute(conn)
.map_res("Error deleting expired send access otps")
})
.await
}
pub async fn find(send_uuid: &SendId, email: &str, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| {
send_access_otps::table
.filter(send_access_otps::send_uuid.eq(send_uuid))
.filter(send_access_otps::email.eq(email))
.first::<Self>(conn)
.ok()
})
.await
}
}
#[derive(
Clone,
Debug,

15
src/db/schema.rs

@ -151,6 +151,19 @@ table! {
deletion_date -> Timestamp,
disabled -> Bool,
hide_email -> Nullable<Bool>,
auth_type -> Integer,
emails -> Nullable<Text>,
}
}
table! {
send_access_otps (send_uuid, email) {
send_uuid -> Text,
email -> Text,
code -> Text,
attempts -> Integer,
creation_date -> Timestamp,
expiration_date -> Timestamp,
}
}
@ -368,6 +381,7 @@ joinable!(folders_ciphers -> folders (folder_uuid));
joinable!(org_policies -> organizations (org_uuid));
joinable!(sends -> organizations (organization_uuid));
joinable!(sends -> users (user_uuid));
joinable!(send_access_otps -> sends (send_uuid));
joinable!(twofactor -> users (user_uuid));
joinable!(users_collections -> collections (collection_uuid));
joinable!(users_collections -> users (user_uuid));
@ -398,6 +412,7 @@ allow_tables_to_appear_in_same_query!(
org_policies,
organizations,
sends,
send_access_otps,
sso_users,
twofactor,
users,

13
src/mail.rs

@ -671,6 +671,19 @@ pub async fn send_admin_account_recovery(
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_send_access_otp(address: &str, token: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/send_access_otp",
json!({
"url": CONFIG.domain(),
"img_src": CONFIG._smtp_img_src(),
"token": token,
}),
)?;
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_protected_action_token(address: &str, token: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/protected_action",

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

@ -0,0 +1,6 @@
Your Vaultwarden Send Verification Code
<!---------------->
Your email verification code is: {{token}}
Use this code to view the Send that was shared with you.
{{> email/email_footer_text }}

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

@ -0,0 +1,16 @@
Your Vaultwarden Send Verification Code
<!---------------->
{{> 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">
Your email verification code is: <b>{{token}}</b>
</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">
Use this code to view the Send that was shared with you.
</td>
</tr>
</table>
{{> email/email_footer }}
Loading…
Cancel
Save