From c46c22139b6d8797d8fb8b3e68826d1769bd6a40 Mon Sep 17 00:00:00 2001 From: ICantThinkOfAName-tech Date: Fri, 16 Oct 2026 20:16:31 +0200 Subject: [PATCH 1/6] Add email verification for Sends Allow Sends to be restricted to a list of email addresses. Recipients request a one-time code by email through the `send_access` grant and exchange it for an access token, matching the upstream flow. - Store `auth_type` and `emails` on sends, with an additive migration - One-time codes: single use, constant-time compared, invalidated after 5 wrong attempts, 60s resend cooldown, 5 minute validity - Unlisted emails get the same response as listed ones and no email is sent, so recipients can't be enumerated - Access is gated on the stored auth type and fails closed when a Send doesn't hold the emails or password its auth type requires - Shared create/update handling of authType; it is inferred when missing, like upstream, for clients that don't send it Co-Authored-By: Claude Opus 5.5 --- .../down.sql | 3 + .../up.sql | 15 ++ .../down.sql | 3 + .../up.sql | 15 ++ .../down.sql | 3 + .../up.sql | 15 ++ src/api/core/sends.rs | 187 ++++++++++++++++-- src/api/identity.rs | 4 + src/auth/send.rs | 85 +++++++- src/config.rs | 1 + src/db/models/mod.rs | 2 +- src/db/models/send.rs | 154 ++++++++++++++- src/db/schema.rs | 15 ++ src/mail.rs | 13 ++ .../templates/email/send_access_otp.hbs | 6 + .../templates/email/send_access_otp.html.hbs | 16 ++ 16 files changed, 509 insertions(+), 28 deletions(-) create mode 100644 migrations/mysql/2026-10-16-120000_add_send_email_verification/down.sql create mode 100644 migrations/mysql/2026-10-16-120000_add_send_email_verification/up.sql create mode 100644 migrations/postgresql/2026-10-16-120000_add_send_email_verification/down.sql create mode 100644 migrations/postgresql/2026-10-16-120000_add_send_email_verification/up.sql create mode 100644 migrations/sqlite/2026-10-16-120000_add_send_email_verification/down.sql create mode 100644 migrations/sqlite/2026-10-16-120000_add_send_email_verification/up.sql create mode 100644 src/static/templates/email/send_access_otp.hbs create mode 100644 src/static/templates/email/send_access_otp.html.hbs diff --git a/migrations/mysql/2026-10-16-120000_add_send_email_verification/down.sql b/migrations/mysql/2026-10-16-120000_add_send_email_verification/down.sql new file mode 100644 index 00000000..24dc2a84 --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-10-16-120000_add_send_email_verification/up.sql b/migrations/mysql/2026-10-16-120000_add_send_email_verification/up.sql new file mode 100644 index 00000000..79e76ed9 --- /dev/null +++ b/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 +); diff --git a/migrations/postgresql/2026-10-16-120000_add_send_email_verification/down.sql b/migrations/postgresql/2026-10-16-120000_add_send_email_verification/down.sql new file mode 100644 index 00000000..24dc2a84 --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-10-16-120000_add_send_email_verification/up.sql b/migrations/postgresql/2026-10-16-120000_add_send_email_verification/up.sql new file mode 100644 index 00000000..bc5ff962 --- /dev/null +++ b/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 +); diff --git a/migrations/sqlite/2026-10-16-120000_add_send_email_verification/down.sql b/migrations/sqlite/2026-10-16-120000_add_send_email_verification/down.sql new file mode 100644 index 00000000..24dc2a84 --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-10-16-120000_add_send_email_verification/up.sql b/migrations/sqlite/2026-10-16-120000_add_send_email_verification/up.sql new file mode 100644 index 00000000..52d20307 --- /dev/null +++ b/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 +); diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 2a604b74..192cc52c 100644 --- a/src/api/core/sends.rs +++ b/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}, @@ -16,7 +16,7 @@ use crate::{ config::PathType, db::{ DbConn, DbPool, - models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId}, + models::{Device, OrgPolicy, OrgPolicyType, Send, SendAuthType, SendFileId, SendId, SendType, UserId}, }, util::{NumberOrString, save_temp_file}, }; @@ -77,6 +77,7 @@ pub struct SendData { deletion_date: DateTime, disabled: bool, hide_email: Option, + auth_type: Option, emails: Option, // Data field @@ -148,10 +149,6 @@ 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; @@ -164,11 +161,85 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { 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)?; Ok(send) } +/// 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 { + let emails: Vec = 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, + emails: Option<&str>, + password: Option, +) -> 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 { let sends = Send::find_by_user(&headers.user.uuid, &conn); @@ -473,10 +544,6 @@ 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())) @@ -528,10 +595,8 @@ 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)?; send.save(conn).await?; if ut != UpdateType::None { @@ -568,6 +633,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 +646,94 @@ 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_invalid_auth_type() { + let mut send = new_send(); + assert!(apply_send_auth(&mut send, Some(42), None, None).is_err()); + } +} diff --git a/src/api/identity.rs b/src/api/identity.rs index b61258cf..6ff70057 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -105,6 +105,8 @@ async fn login(data: Form, 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, password_hash_b64: Option, + email: Option, + otp: Option, } fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { if value.is_none() { diff --git a/src/auth/send.rs b/src/auth/send.rs index 518b7098..29c8e976 100644 --- a/src/auth/send.rs +++ b/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 { + fn expected_error(msg: &str, error_type: &str) -> ApiResult { 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 { + fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult { 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, + email: Option, + otp: Option, ip: &ClientIp, conn: &DbConn, ) -> ApiResult { @@ -89,8 +95,10 @@ impl SendTokens { 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,65 @@ 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, otp: Option, 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()); + let new_otp = SendAccessOtp::new(send.uuid.clone(), email.clone(), code.clone()); + new_otp.save(conn).await?; + if let Err(e) = mail::send_send_access_otp(&email, &code).await { + new_otp.delete(conn).await.ok(); + return Err(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 >= SendAccessOtp::MAX_ATTEMPTS { + 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, } diff --git a/src/config.rs b/src/config.rs index a56748f4..92f7ff6c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1736,6 +1736,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"); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..2ee6550c 100644 --- a/src/db/models/mod.rs +++ b/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; diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 721378f3..81b3a2ae 100644 --- a/src/db/models/send.rs +++ b/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,6 +52,8 @@ pub struct Send { pub disabled: bool, pub hide_email: Option, + pub auth_type: i32, + pub emails: Option, } #[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] @@ -57,10 +62,10 @@ pub enum SendType { File = 1, } -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 +101,8 @@ impl Send { disabled: false, hide_email: None, + auth_type: SendAuthType::None as i32, + emails: None, } } @@ -115,6 +122,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)) => { @@ -166,7 +184,8 @@ impl Send { "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), @@ -282,17 +301,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 { @@ -361,6 +383,124 @@ 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 { + /// How long a code stays valid. + const VALIDITY_MINUTES: i64 = 5; + /// Wrong guesses allowed before a code is discarded. + pub const MAX_ATTEMPTS: i32 = 5; + /// 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, + expiration_date: now + TimeDelta::minutes(Self::VALIDITY_MINUTES), + } + } + + 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 { + 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::(conn) + .ok() + }) + .await + } +} + #[derive( Clone, Debug, diff --git a/src/db/schema.rs b/src/db/schema.rs index ae1e3216..d651b430 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -151,6 +151,19 @@ table! { deletion_date -> Timestamp, disabled -> Bool, hide_email -> Nullable, + auth_type -> Integer, + emails -> Nullable, + } +} + +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, diff --git a/src/mail.rs b/src/mail.rs index 2efb8d70..4f0c1537 100644 --- a/src/mail.rs +++ b/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", diff --git a/src/static/templates/email/send_access_otp.hbs b/src/static/templates/email/send_access_otp.hbs new file mode 100644 index 00000000..58c23559 --- /dev/null +++ b/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. It expires in 5 minutes. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/send_access_otp.html.hbs b/src/static/templates/email/send_access_otp.html.hbs new file mode 100644 index 00000000..8e03b9f8 --- /dev/null +++ b/src/static/templates/email/send_access_otp.html.hbs @@ -0,0 +1,16 @@ +Your Vaultwarden Send Verification Code + +{{> email/email_header }} + + + + + + + +
+ Your email verification code is: {{token}} +
+ Use this code to view the Send that was shared with you. It expires in 5 minutes. +
+{{> email/email_footer }} From 024e3a66894d77433640d546e2923c3f206fe6c4 Mon Sep 17 00:00:00 2001 From: ICantThinkOfAName-tech Date: Fri, 16 Oct 2026 21:02:21 +0200 Subject: [PATCH 2/6] Add Item Sends (temporary vault item sharing) Support the Item Send type used by the Bitwarden clients (2026.10+) to share a single vault item through a temporary link. - New `Item` Send type; the item is encrypted client side into one opaque blob, stored with its item id in clear - Item Sends require email verification - Only items the user owns, or organization items they can edit and see the password of, can be shared; deleted, archived and SSH key items can't. Access is checked on creation and when the item changes - Accesses are counted like Text Sends, so one-time links work - Opt-in through the `pm-34203-temporary-item-sharing` client feature flag; while disabled, Item Sends can't be created and existing ones are hidden from owners and recipients Co-Authored-By: Claude Opus 5.5 --- src/api/core/ciphers.rs | 13 +++- src/api/core/mod.rs | 2 +- src/api/core/sends.rs | 155 ++++++++++++++++++++++++++++++++++++++-- src/auth/send.rs | 2 +- src/config.rs | 2 + src/db/models/send.rs | 3 + 6 files changed, 165 insertions(+), 12 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 46766896..75c69a68 100644 --- a/src/api/core/ciphers.rs +++ b/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}, +}; pub fn routes() -> Vec { // Note that many routes have an `admin` variant; this seems to be @@ -156,8 +159,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option = Folder::find_by_user(&headers.user.uuid, &conn).await.iter().map(Folder::to_json).collect(); - let sends_json: Vec = - Send::find_by_user(&headers.user.uuid, &conn).await.iter().map(Send::to_json).collect(); + let sends_json: Vec = Send::find_by_user(&headers.user.uuid, &conn) + .await + .iter() + .filter(|s| send_visible(s)) + .map(Send::to_json) + .collect(); let policies_json: Vec = OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect(); diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index e6a184dd..f04b05fc 100644 --- a/src/api/core/mod.rs +++ b/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}; diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 192cc52c..2e2381ed 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -16,9 +16,12 @@ use crate::{ config::PathType, db::{ DbConn, DbPool, - models::{Device, OrgPolicy, OrgPolicyType, Send, SendAuthType, 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"; @@ -85,6 +88,7 @@ pub struct SendData { notes: Option, text: Option, file: Option, + data: Option, file_length: Option, // Used for key rotations @@ -132,6 +136,8 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { 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") }; @@ -163,10 +169,105 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { 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) } +const ITEM_SENDS_FEATURE_FLAG: &str = "pm-34203-temporary-item-sharing"; + +/// Item Sends (sharing a vault item through a temporary link) are opt-in through the client feature flag. +pub fn item_sends_enabled() -> bool { + parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly) + .contains_key(ITEM_SENDS_FEATURE_FLAG) +} + +/// Whether a Send can be shown to its owner or 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() +} + +/// 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) -> ApiResult { + 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::(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 { + serde_json::from_str::(&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 { @@ -243,7 +344,7 @@ fn enforce_mail_enabled_for_email_auth(send: &Send) -> EmptyResult { #[get("/sends")] async fn get_sends(headers: Headers, conn: DbConn) -> Json { let sends = Send::find_by_user(&headers.user.uuid, &conn); - let sends_json: Vec = sends.await.iter().map(Send::to_json).collect(); + let sends_json: Vec = sends.await.iter().filter(|s| send_visible(s)).map(Send::to_json).collect(); Json(json!({ "data": sends_json, @@ -254,7 +355,9 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json { #[get("/sends/")] 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 { + if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await + && send_visible(&send) + { Ok(Json(send.to_json())) } else { err!("Send not found", "Invalid send uuid or does not belong to user") @@ -272,7 +375,10 @@ async fn post_send(data: Json, 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, @@ -439,11 +545,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 @@ -581,6 +689,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; @@ -597,6 +712,7 @@ async fn update_send_from_data( 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 { @@ -731,6 +847,31 @@ mod tests { 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(); diff --git a/src/auth/send.rs b/src/auth/send.rs index 29c8e976..0b57f38a 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -91,7 +91,7 @@ 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); } diff --git a/src/config.rs b/src/config.rs index 92f7ff6c..014fef60 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1439,6 +1439,8 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "pm-34171-card-scanner", // Platform Team "pm-30529-webauthn-related-origins", + // Tools Team + "pm-34203-temporary-item-sharing", // Vault Team "pm-32009-new-item-types", ]; diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 81b3a2ae..736981de 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -60,6 +60,7 @@ pub struct Send { pub enum SendType { Text = 0, File = 1, + Item = 2, } #[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] @@ -179,6 +180,7 @@ 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, @@ -211,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, From 98847ad185ee98c32c7a5c521f28ac7667eea44a Mon Sep 17 00:00:00 2001 From: ICantThinkOfAName-tech Date: Fri, 16 Oct 2026 21:28:21 +0200 Subject: [PATCH 3/6] Require the SDK Sends flag for Item Sends Clients only decrypt (and rotate the keys of) Item Sends through the SDK, gated by `pm-30110-sdk-sends-api`. Without it they silently drop Item Sends and recipients can't open them, so support that flag and only enable Item Sends when both flags are set. Co-Authored-By: Claude Opus 5.5 --- src/api/core/sends.rs | 12 +++++++++--- src/config.rs | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 2e2381ed..76c8273c 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -175,11 +175,17 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { } 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 flag. +/// 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 { - parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly) - .contains_key(ITEM_SENDS_FEATURE_FLAG) + 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 shown to its owner or a recipient, Item Sends are hidden while the feature is disabled. diff --git a/src/config.rs b/src/config.rs index 014fef60..8746d399 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1440,6 +1440,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ // 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", From 19ce9e9252bad6222a9e1ef312f16f68eb5d1044 Mon Sep 17 00:00:00 2001 From: ICantThinkOfAName-tech Date: Fri, 16 Oct 2026 21:45:37 +0200 Subject: [PATCH 4/6] Hide Item Sends from clients that can't handle them The mobile apps and the CLI don't know the Item Send type, and an unknown Send type makes the whole Android sync fail. Other clients support it since 2026.10.0. Leave Item Sends out of sync, list and get for those clients, and only advertise the item sharing flag to clients that support it. Co-Authored-By: Claude Opus 5.5 --- src/api/core/ciphers.rs | 6 +++--- src/api/core/mod.rs | 9 +++++++-- src/api/core/sends.rs | 41 ++++++++++++++++++++++++++++++++++------- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 75c69a68..7234c2da 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -31,7 +31,7 @@ use crate::{ use super::{ folders::FolderData, - sends::{SIZE_525_MB, send_visible}, + sends::{SIZE_525_MB, send_visible_to}, }; pub fn routes() -> Vec { @@ -130,7 +130,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option=2024.12.0").unwrap(); ver_match.matches(&client_version.0) } else { @@ -162,7 +162,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option = Send::find_by_user(&headers.user.uuid, &conn) .await .iter() - .filter(|s| send_visible(s)) + .filter(|s| send_visible_to(s, &headers.device, client_version.as_ref())) .map(Send::to_json) .collect(); diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index f04b05fc..b6a737a1 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -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 { } #[get("/config")] -fn config() -> Json { +#[expect(clippy::needless_pass_by_value, reason = "Rocket request guards are passed by value")] +fn config(client_version: Option) -> Json { 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 { &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 diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 76c8273c..8922aca6 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::{Headers, Host, SendHeaders}, + auth::{ClientVersion, Headers, Host, SendHeaders}, config::PathType, db::{ DbConn, DbPool, @@ -174,7 +174,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { Ok(send) } -const ITEM_SENDS_FEATURE_FLAG: &str = "pm-34203-temporary-item-sharing"; +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"; @@ -188,11 +188,28 @@ pub fn item_sends_enabled() -> bool { flags.contains_key(ITEM_SENDS_FEATURE_FLAG) && flags.contains_key(SDK_SENDS_FEATURE_FLAG) } -/// Whether a Send can be shown to its owner or a recipient, Item Sends are hidden while the feature is disabled. +/// 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 = + 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. /// @@ -348,9 +365,14 @@ fn enforce_mail_enabled_for_email_auth(send: &Send) -> EmptyResult { } #[get("/sends")] -async fn get_sends(headers: Headers, conn: DbConn) -> Json { +async fn get_sends(headers: Headers, client_version: Option, conn: DbConn) -> Json { let sends = Send::find_by_user(&headers.user.uuid, &conn); - let sends_json: Vec = sends.await.iter().filter(|s| send_visible(s)).map(Send::to_json).collect(); + let sends_json: Vec = 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, @@ -360,9 +382,14 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json { } #[get("/sends/")] -async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult { +async fn get_send( + send_id: SendId, + headers: Headers, + client_version: Option, + conn: DbConn, +) -> JsonResult { if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await - && send_visible(&send) + && send_visible_to(&send, &headers.device, client_version.as_ref()) { Ok(Json(send.to_json())) } else { From 19e855d35fd122a3165b2ec09c32ab889b7e3033 Mon Sep 17 00:00:00 2001 From: ICantThinkOfAName-tech Date: Fri, 16 Oct 2026 21:45:37 +0200 Subject: [PATCH 5/6] Send the Send verification email in the background Sending the code only for allowed emails made those responses take as long as the SMTP round trip, which tells allowed and unknown emails apart. Mail the code in the background so both answer the same way. Co-Authored-By: Claude Opus 5.5 --- src/auth/send.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/auth/send.rs b/src/auth/send.rs index 0b57f38a..d4da2470 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -156,12 +156,14 @@ impl SendTokens { 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()); - let new_otp = SendAccessOtp::new(send.uuid.clone(), email.clone(), code.clone()); - new_otp.save(conn).await?; - if let Err(e) = mail::send_send_access_otp(&email, &code).await { - new_otp.delete(conn).await.ok(); - return Err(e); - } + 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"); }; From 8c551de2abc4937d5c21d6ac1a5dfe99154f7032 Mon Sep 17 00:00:00 2001 From: ICantThinkOfAName-tech Date: Fri, 16 Oct 2026 21:48:10 +0200 Subject: [PATCH 6/6] Use the email token settings for Send verification codes Reuse `EMAIL_EXPIRATION_TIME` and `EMAIL_ATTEMPTS_LIMIT`, like the email 2FA codes, instead of hardcoded values. Co-Authored-By: Claude Opus 5.5 --- src/auth/send.rs | 2 +- src/db/models/send.rs | 12 +++++++----- src/static/templates/email/send_access_otp.hbs | 2 +- src/static/templates/email/send_access_otp.html.hbs | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/auth/send.rs b/src/auth/send.rs index d4da2470..64bf6a14 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -171,7 +171,7 @@ impl SendTokens { let Some(stored) = stored else { return Self::expected_error("Email and OTP required", "email_and_otp_required"); }; - if stored.is_expired() || stored.attempts >= SendAccessOtp::MAX_ATTEMPTS { + if stored.is_expired() || stored.attempts_exhausted() { stored.delete(conn).await?; return Self::expected_error("Email and OTP required", "email_and_otp_required"); } diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 736981de..7cfc42d0 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -400,10 +400,6 @@ pub struct SendAccessOtp { } impl SendAccessOtp { - /// How long a code stays valid. - const VALIDITY_MINUTES: i64 = 5; - /// Wrong guesses allowed before a code is discarded. - pub const MAX_ATTEMPTS: i32 = 5; /// 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; @@ -416,10 +412,16 @@ impl SendAccessOtp { code, attempts: 0, creation_date: now, - expiration_date: now + TimeDelta::minutes(Self::VALIDITY_MINUTES), + // 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 } diff --git a/src/static/templates/email/send_access_otp.hbs b/src/static/templates/email/send_access_otp.hbs index 58c23559..50a62255 100644 --- a/src/static/templates/email/send_access_otp.hbs +++ b/src/static/templates/email/send_access_otp.hbs @@ -2,5 +2,5 @@ Your Vaultwarden Send Verification Code Your email verification code is: {{token}} -Use this code to view the Send that was shared with you. It expires in 5 minutes. +Use this code to view the Send that was shared with you. {{> email/email_footer_text }} diff --git a/src/static/templates/email/send_access_otp.html.hbs b/src/static/templates/email/send_access_otp.html.hbs index 8e03b9f8..ffbe529d 100644 --- a/src/static/templates/email/send_access_otp.html.hbs +++ b/src/static/templates/email/send_access_otp.html.hbs @@ -9,7 +9,7 @@ Your Vaultwarden Send Verification Code - Use this code to view the Send that was shared with you. It expires in 5 minutes. + Use this code to view the Send that was shared with you.