Browse Source

Add Item Sends and email-verified Send access

Support the "temporary item sharing" flow of the Bitwarden clients
(bitwarden/clients #23060, #23079, #23152), which shares a whole vault
item through a Send link and requires the recipient to verify an email
address with a one-time code.

Since SDK 0.2.0-main.1075 (browser and desktop 2026.10.0) the clients seal
the item in the Send and send its id in clear in metadata.itemId, which is
required (bitwarden/sdk-internal#1539, bitwarden/server#8485).

- SendType::Item (2): store only {encryptionVersion, data} and
  metadata.itemId, capped at 500 kB; the item stays encrypted
  client-side with the Send key. Item Sends without metadata.itemId are
  rejected ("Metadata is required for item sends.").
- When the item is new or changes, the owner of the Send must be able to
  share it: own item, or an organization item they can edit with
  passwords visible; not deleted, not archived, not an SSH key. Missing
  and forbidden items get the same "Item not found.".
- Item Sends in the earlier format, which no current client decrypts, are
  left out of sync, list and get and refused at the access token. They
  are not deleted; the regular purge removes them.
- Email verification without a schema change: the allowed addresses
  live in the existing `sends.data` JSON (`vwAuthEmails`, stripped from
  every response) and the one-time code lives in memory
  (EMAIL_TOKEN_SIZE digits, valid for EMAIL_EXPIRATION_TIME, single use,
  30 s resend cooldown, burned after EMAIL_ATTEMPTS_LIMIT wrong tries;
  a burned code keeps the cooldown).
- Unknown addresses get the same response as allowed ones and no mail
  is sent. The code is mailed in the background, so neither the timing
  nor a mail server failure tells the two apart; failures are logged.
- `authType` and `emails` in the Send JSON; put_send and create_send
  accept `emails` and `authType` instead of rejecting them. Keeping the
  current recipients doesn't need mail, so a key rotation can't fail
  halfway because email was switched off after the Send was created.
- The legacy /sends/access endpoints refuse email-protected Sends, so
  older clients cannot skip the code.
- The two client feature flags (pm-34203-temporary-item-sharing,
  pm-30110-sdk-sends-api) are only advertised to clients at or above
  ITEM_SHARING_MIN_CLIENT_VERSION (default 2026.10.0), and Item Sends
  are hidden from sync/list/get for Android and iOS (the Android app
  has no fallback for unknown Send types and would fail the whole sync)
  and for clients below that version.
- Each successful access logs "Send <id> opened by <email> from <ip>".
- MaybeClientVersion guard for /api/config, so requests without the
  Bitwarden-Client-Version header no longer log an error.
pull/7783/head
Diego Amorim 4 days ago
parent
commit
1355938413
  1. 7
      .env.template
  2. 14
      src/api/core/ciphers.rs
  3. 11
      src/api/core/mod.rs
  4. 388
      src/api/core/sends.rs
  5. 5
      src/api/identity.rs
  6. 18
      src/auth.rs
  7. 214
      src/auth/send.rs
  8. 18
      src/config.rs
  9. 2
      src/db/models/mod.rs
  10. 193
      src/db/models/send.rs
  11. 14
      src/mail.rs
  12. 6
      src/static/templates/email/send_otp.hbs
  13. 16
      src/static/templates/email/send_otp.html.hbs

7
.env.template

@ -403,8 +403,15 @@
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0) ## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0) ## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0) ## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
## - "pm-30110-sdk-sends-api": Handle Sends through the SDK, needed by temporary item sharing (Clients >= ITEM_SHARING_MIN_CLIENT_VERSION)
## - "pm-34203-temporary-item-sharing": Share a whole vault item through an email verified Send link (Clients >= ITEM_SHARING_MIN_CLIENT_VERSION)
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS= # EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Minimum client version for temporary item sharing. Item Sends and the two flags above are only served to
## clients at or above this version. Older clients and the mobile apps can't parse Item Sends, and an unknown
## Send type fails their whole sync.
# ITEM_SHARING_MIN_CLIENT_VERSION=2026.10.0
## Require new device emails. When a user logs in an email is required to be sent. ## Require new device emails. When a user logs in an email is required to be sent.
## If sending the email fails the login attempt will fail!! ## If sending the email fails the login attempt will fail!!
# REQUIRE_DEVICE_EMAIL=false # REQUIRE_DEVICE_EMAIL=false

14
src/api/core/ciphers.rs

@ -23,7 +23,7 @@ use crate::{
models::{ models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, SendType, UserId,
}, },
}, },
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@ -127,7 +127,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; 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 // 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(); let ver_match = semver::VersionReq::parse(">=2024.12.0").unwrap();
ver_match.matches(&client_version.0) ver_match.matches(&client_version.0)
} else { } else {
@ -156,8 +156,14 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
let folders_json: Vec<Value> = let folders_json: Vec<Value> =
Folder::find_by_user(&headers.user.uuid, &conn).await.iter().map(Folder::to_json).collect(); Folder::find_by_user(&headers.user.uuid, &conn).await.iter().map(Folder::to_json).collect();
let sends_json: Vec<Value> = // Item Sends fail the whole sync of clients that don't know them (the Android app has no fallback)
Send::find_by_user(&headers.user.uuid, &conn).await.iter().map(Send::to_json).collect(); let show_item_sends = api::core::sends::client_supports_item_sends(headers.device.atype, client_version.as_ref());
let sends_json: Vec<Value> = Send::find_by_user(&headers.user.uuid, &conn)
.await
.iter()
.filter(|s| s.atype != SendType::Item as i32 || (show_item_sends && !s.is_legacy_item()))
.map(Send::to_json)
.collect();
let policies_json: Vec<Value> = let policies_json: Vec<Value> =
OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect(); OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect();

11
src/api/core/mod.rs

@ -21,7 +21,7 @@ use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
use crate::{ use crate::{
CONFIG, CONFIG,
api::{EmptyResult, JsonResult, Notify, UpdateType}, api::{EmptyResult, JsonResult, Notify, UpdateType},
auth::Headers, auth::{Headers, MaybeClientVersion},
db::{ db::{
DbConn, DbConn,
models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
@ -208,7 +208,7 @@ fn get_api_webauthn(_headers: Headers) -> Json<Value> {
} }
#[get("/config")] #[get("/config")]
fn config() -> Json<Value> { fn config(client_version: MaybeClientVersion) -> Json<Value> {
let domain = CONFIG.domain(); let domain = CONFIG.domain();
// Official available feature flags can be found here: // Official available feature flags can be found here:
// Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135 // Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135
@ -220,6 +220,13 @@ fn config() -> Json<Value> {
&FeatureFlagFilter::ValidOnly, &FeatureFlagFilter::ValidOnly,
); );
feature_states.insert("pm-19148-innovation-archive".to_owned(), true); feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
// These flags switch every Send of the client to the SDK and enable Item Sends, which older
// clients can't handle: only clients at or above `ITEM_SHARING_MIN_CLIENT_VERSION` get them
if !client_version.0.is_some_and(|v| sends::client_version_supports_item_sharing(Some(&v))) {
for flag in crate::config::ITEM_SHARING_FEATURE_FLAGS {
feature_states.remove(*flag);
}
}
Json(json!({ Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns // Note: The clients use this version to handle backwards compatibility concerns

388
src/api/core/sends.rs

@ -1,7 +1,7 @@
use std::{path::Path, sync::LazyLock, time::Duration}; use std::{path::Path, sync::LazyLock, time::Duration};
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use num_traits::ToPrimitive; use num_traits::{FromPrimitive, ToPrimitive};
use rocket::{ use rocket::{
form::Form, form::Form,
fs::{NamedFile, TempFile}, fs::{NamedFile, TempFile},
@ -12,11 +12,14 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{Headers, Host, SendHeaders}, auth::{ClientVersion, Headers, Host, SendHeaders},
config::PathType, config::PathType,
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId}, models::{
Cipher, CipherId, Device, DeviceType, OrgPolicy, OrgPolicyType, Send, SendAuthType, SendFileId, SendId,
SendType, UserId, normalize_emails,
},
}, },
util::{NumberOrString, save_temp_file}, util::{NumberOrString, save_temp_file},
}; };
@ -70,6 +73,7 @@ pub async fn purge_sends(pool: DbPool) {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SendData { pub struct SendData {
r#type: i32, r#type: i32,
auth_type: Option<i32>,
pub key: String, pub key: String,
password: Option<String>, password: Option<String>,
max_access_count: Option<NumberOrString>, max_access_count: Option<NumberOrString>,
@ -84,6 +88,8 @@ pub struct SendData {
notes: Option<String>, notes: Option<String>,
text: Option<Value>, text: Option<Value>,
file: Option<Value>, file: Option<Value>,
// Item Sends: `{ encryptionVersion, data }`, the whole shared item encrypted client side
data: Option<Value>,
file_length: Option<NumberOrString>, file_length: Option<NumberOrString>,
// Used for key rotations // Used for key rotations
@ -126,20 +132,191 @@ async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, c
Ok(()) Ok(())
} }
/// Max length of the encrypted blob of an Item Send, the same limit as the Bitwarden server
const ITEM_DATA_MAX_LEN: usize = 500_000;
/// Max recipients of an email verified Send
const SEND_EMAILS_MAX: usize = 50;
/// Whether temporary item sharing (`pm-34203-temporary-item-sharing`) is enabled on this server
pub fn item_sharing_enabled() -> bool {
crate::util::parse_experimental_client_feature_flags(
&CONFIG.experimental_client_feature_flags(),
&crate::util::FeatureFlagFilter::ValidOnly,
)
.contains_key("pm-34203-temporary-item-sharing")
}
/// Whether this client version is recent enough for Item Sends and the SDK Sends API.
/// A client that does not tell its version is treated as too old.
pub fn client_version_supports_item_sharing(client_version: Option<&ClientVersion>) -> bool {
let Ok(min) = semver::Version::parse(&CONFIG.item_sharing_min_client_version()) else {
return false;
};
client_version.is_some_and(|v| v.0 >= min)
}
/// Whether Item Sends can be listed to this client. An unknown Send type fails the whole sync of the
/// Android app (its Send type enum has no fallback), and the mobile apps follow their own release
/// train, so they never get Item Sends whatever version they report.
pub fn client_supports_item_sends(device_type: i32, client_version: Option<&ClientVersion>) -> bool {
const MOBILE: [i32; 3] = [DeviceType::Android as i32, DeviceType::Ios as i32, DeviceType::AndroidAmazon as i32];
item_sharing_enabled() && client_version_supports_item_sharing(client_version) && !MOBILE.contains(&device_type)
}
/// Validates the encrypted blob of an Item Send and keeps only the fields the server stores.
fn item_data_str(data: Option<&Value>) -> ApiResult<String> {
let Some(d) = data else {
err!("Send data not provided")
};
let blob = d.get("data").or_else(|| d.get("Data")).and_then(Value::as_str).unwrap_or_default();
if blob.is_empty() {
err!("Item Sends need the encrypted item data")
}
if blob.len() > ITEM_DATA_MAX_LEN {
err!("The shared item is too large")
}
// The id of the shared item travels unencrypted next to the sealed item, and the clients need it back
// to decrypt (bitwarden/server#8485). Required, as on the Bitwarden server: an Item Send without it
// is in the old format, which no client decrypts anymore.
let metadata = d.get("metadata").or_else(|| d.get("Metadata"));
let item_id = metadata.and_then(|m| m.get("itemId").or_else(|| m.get("ItemId"))).and_then(Value::as_str);
let Some(item_id) = item_id.and_then(|i| uuid::Uuid::parse_str(i).ok()) else {
err!("Metadata is required for item sends.")
};
let version =
d.get("encryptionVersion").or_else(|| d.get("EncryptionVersion")).and_then(Value::as_i64).unwrap_or(1);
Ok(serde_json::to_string(&json!({
"encryptionVersion": version,
"data": blob,
"metadata": { "itemId": item_id.to_string() },
}))?)
}
/// Lets an Item Send share only what its owner may share, as the Bitwarden server does: their own item,
/// or an organization item they can edit and see the password of, and never a deleted, archived or SSH
/// key item. A save keeping the stored item id skips the check, so a Send whose item was deleted since
/// can still be edited. Missing and restricted items get the same error, so item ids can't be probed.
async fn validate_item_send(
send: &Send,
stored_item_id: Option<CipherId>,
user_id: &UserId,
conn: &DbConn,
) -> EmptyResult {
const SSH_KEY: i32 = 5;
let Some(item_id) = send.item_id() else {
return Ok(());
};
if stored_item_id.as_ref() == Some(&item_id) {
return Ok(());
}
let Some(cipher) = Cipher::find_by_uuid(&item_id, conn).await else {
err!("Item not found.", format!("Item Send of {user_id}: item {item_id} does not exist"))
};
let can_share = matches!(cipher.get_access_restrictions(user_id, None, conn).await, Some((false, false, _)));
if !can_share
|| cipher.deleted_at.is_some()
|| cipher.atype == SSH_KEY
|| cipher.get_archived_at(user_id, conn).await.is_some()
{
err!("Item not found.", format!("Item Send of {user_id}: item {item_id} can't be shared by this user"))
}
Ok(())
}
fn set_send_emails(send: &mut Send, emails: &[String], current: Option<&[String]>) -> EmptyResult {
// Keeping the current recipients doesn't need mail: a key rotation re-saves every Send after other
// writes, and must not fail halfway because email was switched off after the Send was created
let unchanged = current.is_some_and(|c| {
let (mut a, mut b) = (c.to_vec(), emails.to_vec());
a.sort_unstable();
b.sort_unstable();
a == b
});
if !unchanged && !CONFIG.mail_enabled() {
err!("Email verified Sends need email to be configured on this server")
}
if emails.len() > SEND_EMAILS_MAX {
err!(format!("A Send can be verified by at most {SEND_EMAILS_MAX} emails"))
}
if emails.iter().any(|e| e.len() > 254 || !e.contains('@')) {
err!("Invalid email address")
}
send.set_auth_emails(Some(&emails.join(",")))
}
/// Applies the requested access control, following the Bitwarden server: `authType` decides, and a
/// request carrying the type without its secret keeps the current one (the SDK edits that way when
/// the auth is not being changed). An existing email gate is never dropped implicitly: only an
/// explicit `authType` does it, unlike the Bitwarden server, which clears it for older clients.
fn apply_send_auth(
send: &mut Send,
auth_type: Option<i32>,
password: Option<&str>,
emails: Option<&str>,
current_emails: Option<&[String]>,
) -> EmptyResult {
let requested_emails = emails.map(normalize_emails).filter(|e| !e.is_empty());
match auth_type.map(SendAuthType::from_i32) {
Some(None) => err!("Invalid Send auth type"),
Some(Some(SendAuthType::Email)) => {
let Some(list) = requested_emails.or_else(|| current_emails.map(<[String]>::to_vec)) else {
err!("Email verified Sends need at least one email")
};
set_send_emails(send, &list, current_emails)?;
}
Some(Some(SendAuthType::Password)) => {
if password.is_none() && send.password_hash.is_none() {
err!("Password protected Sends need a password")
}
send.set_auth_emails(None)?;
if let Some(p) = password {
send.set_password(Some(p));
}
}
Some(Some(SendAuthType::None)) => {
send.set_auth_emails(None)?;
send.set_password(None);
}
// Clients from before the SDK Sends API don't send `authType`
None => {
if let Some(list) = requested_emails {
set_send_emails(send, &list, current_emails)?;
} else if let Some(p) = password {
send.set_auth_emails(None)?;
send.set_password(Some(p));
} else if let Some(list) = current_emails {
set_send_emails(send, list, Some(list))?;
}
}
}
if send.atype == SendType::Item as i32 && send.auth_emails().is_none() {
err!("Item Sends require email verification")
}
Ok(())
}
fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> { fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
let data_val = if data.r#type == SendType::Text as i32 { let data_str = match SendType::from_i32(data.r#type) {
Some(send_type @ (SendType::Text | SendType::File)) => {
let data_val = if send_type == SendType::Text {
data.text data.text
} else if data.r#type == SendType::File as i32 {
data.file
} else { } else {
err!("Invalid Send type") data.file
};
let Some(mut d) = data_val else {
err!("Send data not provided");
}; };
let data_str = if let Some(mut d) = data_val {
d.as_object_mut().and_then(|o| o.remove("response")); d.as_object_mut().and_then(|o| o.remove("response"));
serde_json::to_string(&d)? serde_json::to_string(&d)?
} else { }
err!("Send data not provided"); Some(SendType::Item) => {
if !item_sharing_enabled() {
err!("Item Sends are not enabled on this server")
}
item_data_str(data.data.as_ref())?
}
None => err!("Invalid Send type"),
}; };
if data.deletion_date > Utc::now() + TimeDelta::try_days(31).unwrap() { if data.deletion_date > Utc::now() + TimeDelta::try_days(31).unwrap() {
@ -148,10 +325,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()); 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.user_uuid = Some(user_id);
send.notes = data.notes; send.notes = data.notes;
@ -164,15 +337,22 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
send.hide_email = data.hide_email; send.hide_email = data.hide_email;
send.atype = data.r#type; send.atype = data.r#type;
send.set_password(data.password.as_deref()); // A client could put the server side email list inside its own data: start from none
send.set_auth_emails(None)?;
apply_send_auth(&mut send, data.auth_type, data.password.as_deref(), data.emails.as_deref(), None)?;
Ok(send) Ok(send)
} }
#[get("/sends")] #[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 show_items = client_supports_item_sends(headers.device.atype, client_version.as_ref());
let sends_json: Vec<Value> = sends.await.iter().map(Send::to_json).collect(); let sends = Send::find_by_user(&headers.user.uuid, &conn).await;
let sends_json: Vec<Value> = sends
.iter()
.filter(|s| s.atype != SendType::Item as i32 || (show_items && !s.is_legacy_item()))
.map(Send::to_json)
.collect();
Json(json!({ Json(json!({
"data": sends_json, "data": sends_json,
@ -182,11 +362,21 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
} }
#[get("/sends/<send_id>")] #[get("/sends/<send_id>")]
async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult { async fn get_send(
if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await { send_id: SendId,
headers: Headers,
client_version: Option<ClientVersion>,
conn: DbConn,
) -> JsonResult {
match Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
Some(send)
if send.atype != SendType::Item as i32
|| (!send.is_legacy_item()
&& client_supports_item_sends(headers.device.atype, client_version.as_ref())) =>
{
Ok(Json(send.to_json())) Ok(Json(send.to_json()))
} else { }
err!("Send not found", "Invalid send uuid or does not belong to user") _ => err!("Send not found", "Invalid send uuid or does not belong to user"),
} }
} }
@ -201,7 +391,8 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
err!("File sends should use /api/sends/file/v2") 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())?;
validate_item_send(&send, None, &headers.user.uuid, &conn).await?;
send.save(&conn).await?; send.save(&conn).await?;
nt.send_send_update( nt.send_send_update(
UpdateType::SyncSendCreate, UpdateType::SyncSendCreate,
@ -368,11 +559,11 @@ 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 { let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
if !send.is_accessible() { if !send.is_accessible() || (send.atype == SendType::Item as i32 && !item_sharing_enabled()) {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
// Files are incremented during the download // Files are incremented during the download, text and item Sends here
if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? { if send.atype != SendType::File as i32 && !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
process_access(send, conn, nt).await process_access(send, conn, nt).await
@ -473,10 +664,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") 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?; update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?;
Ok(Json(send.to_json())) Ok(Json(send.to_json()))
@ -504,8 +691,12 @@ async fn update_send_from_data(
); );
} }
// The email list lives inside `data`, which a Text or Item update replaces, and so does the item id
let current_emails = send.auth_emails();
let stored_item_id = send.item_id();
// When updating a file Send, we receive nulls in the File field, as it's immutable, // When updating a file Send, we receive nulls in the File field, as it's immutable,
// so we only need to update the data field in the Text case // so we only need to update the data field in the Text and Item cases
if data.r#type == SendType::Text as i32 { if data.r#type == SendType::Text as i32 {
let data_str = if let Some(mut d) = data.text { let data_str = if let Some(mut d) = data.text {
d.as_object_mut().and_then(|d| d.remove("response")); d.as_object_mut().and_then(|d| d.remove("response"));
@ -514,6 +705,13 @@ async fn update_send_from_data(
err!("Send data not provided"); err!("Send data not provided");
}; };
send.data = data_str; send.data = data_str;
} else if data.r#type == SendType::Item as i32 {
if !item_sharing_enabled() {
err!("Item Sends are not enabled on this server")
}
if data.data.is_some() {
send.data = item_data_str(data.data.as_ref())?;
}
} }
send.name = data.name; send.name = data.name;
@ -528,10 +726,8 @@ async fn update_send_from_data(
send.hide_email = data.hide_email; send.hide_email = data.hide_email;
send.disabled = data.disabled; send.disabled = data.disabled;
// Only change the value if it's present apply_send_auth(send, data.auth_type, data.password.as_deref(), data.emails.as_deref(), current_emails.as_deref())?;
if let Some(password) = data.password { validate_item_send(send, stored_item_id, &headers.user.uuid, conn).await?;
send.set_password(Some(&password));
}
send.save(conn).await?; send.save(conn).await?;
if ut != UpdateType::None { if ut != UpdateType::None {
@ -580,3 +776,123 @@ async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt
Ok(Json(send.to_json())) Ok(Json(send.to_json()))
} }
#[cfg(test)]
mod tests {
use super::*;
fn new_send(atype: SendType) -> Send {
let deletion = (Utc::now() + TimeDelta::try_days(1).unwrap()).naive_utc();
Send::new(atype as i32, "2.name".into(), r#"{"text":"2.t"}"#.into(), "2.key".into(), deletion)
}
#[test]
fn item_data_is_validated_and_trimmed_to_known_fields() {
let item_id = "5d4fbf2b-7a36-4b3c-9f2e-1a6d8c0e9b71";
let metadata = json!({"itemId": item_id, "extra": 1});
let stored = item_data_str(Some(
&json!({"encryptionVersion": 1, "data": "blob", "metadata": metadata, "vwAuthEmails": "x@y.z"}),
))
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&stored).unwrap(),
json!({"encryptionVersion": 1, "data": "blob", "metadata": {"itemId": item_id}})
);
let metadata = json!({"itemId": item_id});
assert!(item_data_str(None).is_err());
assert!(item_data_str(Some(&json!({"encryptionVersion": 1, "data": "", "metadata": metadata}))).is_err());
let too_big = "a".repeat(ITEM_DATA_MAX_LEN + 1);
assert!(item_data_str(Some(&json!({"data": too_big, "metadata": metadata}))).is_err());
}
#[test]
fn item_data_needs_the_item_id() {
assert!(item_data_str(Some(&json!({"encryptionVersion": 1, "data": "blob"}))).is_err(), "old format");
assert!(item_data_str(Some(&json!({"data": "blob", "metadata": {}}))).is_err());
assert!(item_data_str(Some(&json!({"data": "blob", "metadata": {"itemId": "c"}}))).is_err());
assert!(item_data_str(Some(&json!({"data": "blob", "metadata": {"itemId": 7}}))).is_err());
let upper = "5D4FBF2B-7A36-4B3C-9F2E-1A6D8C0E9B71";
let stored = item_data_str(Some(&json!({"Data": "blob", "Metadata": {"ItemId": upper}}))).unwrap();
let stored = serde_json::from_str::<Value>(&stored).unwrap();
assert_eq!(stored["metadata"]["itemId"], upper.to_lowercase(), "stored as the clients write cipher ids");
}
#[test]
fn password_auth_needs_a_password_unless_one_exists() {
let mut send = new_send(SendType::Text);
assert!(apply_send_auth(&mut send, Some(SendAuthType::Password as i32), None, None, None).is_err());
apply_send_auth(&mut send, Some(SendAuthType::Password as i32), Some("hash"), None, None).unwrap();
let hash = send.password_hash.clone();
assert!(hash.is_some());
// The SDK edits with the type and no secret to keep the current password
apply_send_auth(&mut send, Some(SendAuthType::Password as i32), None, None, None).unwrap();
assert_eq!(send.password_hash, hash);
}
#[test]
fn explicit_none_removes_the_password() {
let mut send = new_send(SendType::Text);
send.set_password(Some("hash"));
apply_send_auth(&mut send, Some(SendAuthType::None as i32), None, None, None).unwrap();
assert!(send.password_hash.is_none());
}
#[test]
fn older_clients_keep_the_password_when_they_send_none() {
let mut send = new_send(SendType::Text);
send.set_password(Some("hash"));
apply_send_auth(&mut send, None, None, None, None).unwrap();
assert!(send.password_hash.is_some());
}
#[test]
fn unknown_auth_type_is_refused() {
let mut send = new_send(SendType::Text);
assert!(apply_send_auth(&mut send, Some(9), None, None, None).is_err());
}
#[test]
fn item_sends_can_not_drop_email_verification() {
let mut send = new_send(SendType::Item);
assert!(apply_send_auth(&mut send, Some(SendAuthType::None as i32), None, None, None).is_err());
assert!(apply_send_auth(&mut send, Some(SendAuthType::Password as i32), Some("hash"), None, None).is_err());
assert!(apply_send_auth(&mut send, None, None, None, None).is_err());
}
#[test]
fn email_verified_sends_need_mail() {
// The test config has no SMTP: an email verified Send would be impossible to open
let mut send = new_send(SendType::Text);
assert!(!CONFIG.mail_enabled());
assert!(apply_send_auth(&mut send, Some(SendAuthType::Email as i32), None, Some("a@x.com"), None).is_err());
}
#[test]
fn keeping_the_recipients_needs_no_mail() {
// A key rotation re-saves every Send: switching email off later must not make it fail halfway
assert!(!CONFIG.mail_enabled());
let current = || Some(vec!["a@x.com".to_owned(), "b@x.com".to_owned()]);
let email = Some(SendAuthType::Email as i32);
let mut send = new_send(SendType::Text);
assert!(apply_send_auth(&mut send, email, None, Some("b@x.com, A@x.com"), current().as_deref()).is_ok());
assert!(apply_send_auth(&mut send, email, None, None, current().as_deref()).is_ok());
assert!(apply_send_auth(&mut send, None, None, None, current().as_deref()).is_ok());
assert_eq!(send.auth_emails(), current());
// Changing the recipients still needs mail
assert!(apply_send_auth(&mut send, email, None, Some("a@x.com"), current().as_deref()).is_err());
assert!(apply_send_auth(&mut send, None, None, Some("c@x.com"), current().as_deref()).is_err());
}
#[test]
fn min_client_version_gate() {
let v = |s: &str| ClientVersion(semver::Version::parse(s).unwrap());
assert!(!client_version_supports_item_sharing(None));
assert!(!client_version_supports_item_sharing(Some(&v("2026.9.3"))));
assert!(client_version_supports_item_sharing(Some(&v("2026.10.0"))));
assert!(client_version_supports_item_sharing(Some(&v("2027.1.0"))));
}
}

5
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( let tokens = auth::SendTokens::generate_tokens(
data.send_id.as_ref().unwrap(), data.send_id.as_ref().unwrap(),
data.password_hash_b64, data.password_hash_b64,
data.email,
data.otp,
&client_header.ip, &client_header.ip,
&conn, &conn,
) )
@ -1165,6 +1167,9 @@ struct ConnectData {
// Needed for send access // Needed for send access
send_id: Option<SendId>, send_id: Option<SendId>,
password_hash_b64: Option<String>, password_hash_b64: Option<String>,
// Needed for email verified send access
email: Option<String>,
otp: Option<String>,
} }
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult { fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() { if value.is_none() {

18
src/auth.rs

@ -1234,6 +1234,24 @@ impl<'r> FromRequest<'r> for ClientVersion {
} }
} }
/// Like `Option<ClientVersion>`, but a missing or invalid header is not logged as an error:
/// for endpoints that anonymous pages and every client hit, like `/api/config`.
pub struct MaybeClientVersion(pub Option<ClientVersion>);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for MaybeClientVersion {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let version = request
.headers()
.get_one("Bitwarden-Client-Version")
.and_then(|v| semver::Version::parse(v).ok())
.map(ClientVersion);
Outcome::Success(MaybeClientVersion(version))
}
}
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum AuthMethod { pub enum AuthMethod {

214
src/auth/send.rs

@ -1,17 +1,113 @@
use std::{
collections::HashMap,
sync::{LazyLock, Mutex},
};
use chrono::{TimeDelta, Utc}; use chrono::{TimeDelta, Utc};
use rocket::request::{FromRequest, Outcome, Request}; use rocket::request::{FromRequest, Outcome, Request};
use crate::{ use crate::{
CONFIG,
api::ApiResult, api::ApiResult,
auth, auth,
auth::{BasicJwtClaims, ClientIp}, auth::{BasicJwtClaims, ClientIp},
crypto,
db::{ db::{
DbConn, DbConn,
models::{Send, SendId}, models::{Send, SendAuthType, SendId},
}, },
error::{Error, ErrorKind}, error::{Error, ErrorKind},
mail,
};
/// A verification code sent to one recipient of an email verified Send
struct SendOtp {
/// `None` once too many wrong tries burned it. The entry stays until it expires, so the resend
/// cooldown still counts from `created` and wrong tries can't be used to skip it.
code: Option<String>,
created: i64,
expires: i64,
failed_attempts: u64,
}
/// Pending codes, keyed by Send and email. They live minutes, so they are kept in memory: a restart
/// only makes the recipient ask for a new code, and no table is needed.
static SEND_OTPS: LazyLock<Mutex<HashMap<(SendId, String), SendOtp>>> = LazyLock::new(Default::default);
/// Minimum seconds between two codes for the same recipient of the same Send, so a leaked link can't
/// be used to flood the recipient's inbox
const OTP_RESEND_COOLDOWN_SECS: i64 = 30;
/// Registers a new code for this recipient and returns it, or `None` while the last one, burned or not,
/// is younger than `OTP_RESEND_COOLDOWN_SECS`.
fn reserve_send_otp(send_id: &SendId, email: &str) -> Option<String> {
let now = Utc::now().timestamp();
let key = (send_id.clone(), email.to_owned());
let mut otps = SEND_OTPS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
otps.retain(|_, o| o.expires > now);
if otps.get(&key).is_some_and(|o| now - o.created < OTP_RESEND_COOLDOWN_SECS) {
return None;
}
let code = crypto::generate_email_token(CONFIG.email_token_size());
let expires = now + i64::try_from(CONFIG.email_expiration_time()).unwrap_or(600);
otps.insert(
key,
SendOtp {
code: Some(code.clone()),
created: now,
expires,
failed_attempts: 0,
},
);
Some(code)
}
/// Mails a new code in the background. It is not awaited on purpose: allowed and unknown emails must get
/// the same answer, in the same time, even when the mail server fails, so a failure is only logged.
fn issue_send_otp(send_id: &SendId, email: &str) {
let Some(code) = reserve_send_otp(send_id, email) else {
return;
};
let key = (send_id.clone(), email.to_owned());
tokio::task::spawn(async move {
if let Err(e) = mail::send_send_otp(&key.1, &code).await {
error!("Error sending the verification code of Send {} to {}: {e:#?}", key.0, key.1);
// Let the recipient ask again right away, unless a newer code already replaced this one
let mut otps = SEND_OTPS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if otps.get(&key).is_some_and(|o| o.code.as_deref() == Some(code.as_str())) {
otps.remove(&key);
}
}
});
}
/// Checks a code. A valid code is consumed; after `EMAIL_ATTEMPTS_LIMIT` wrong ones the pending code
/// is burned and the recipient must ask for a new one, which the resend cooldown still applies to.
fn verify_send_otp(send_id: &SendId, email: &str, code: &str) -> bool {
let now = Utc::now().timestamp();
let key = (send_id.clone(), email.to_owned());
let mut otps = SEND_OTPS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(otp) = otps.get_mut(&key) else {
return false;
}; };
if otp.expires <= now {
otps.remove(&key);
return false;
}
let Some(expected) = &otp.code else {
return false;
};
if crypto::ct_eq(expected, code) {
otps.remove(&key);
return true;
}
otp.failed_attempts += 1;
if otp.failed_attempts >= CONFIG.email_attempts_limit() {
otp.code = None;
}
false
}
fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims { fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims {
let time_now = Utc::now(); let time_now = Utc::now();
@ -68,6 +164,8 @@ impl SendTokens {
pub async fn generate_tokens( pub async fn generate_tokens(
access_id: &str, access_id: &str,
password: Option<String>, password: Option<String>,
email: Option<String>,
otp: Option<String>,
ip: &ClientIp, ip: &ClientIp,
conn: &DbConn, conn: &DbConn,
) -> ApiResult<SendTokens> { ) -> ApiResult<SendTokens> {
@ -79,6 +177,15 @@ impl SendTokens {
return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false); return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false);
}; };
// No client decrypts it anymore: refuse before mailing a code for a Send that can't be opened
if send.is_legacy_item() {
return Self::invalid_error(
&format!("Send {send_id}, Item Send in the old format"),
"send_id_invalid",
true,
);
}
if let Some(max_access_count) = send.max_access_count if let Some(max_access_count) = send.max_access_count
&& send.access_count >= max_access_count && send.access_count >= max_access_count
{ {
@ -89,6 +196,40 @@ impl SendTokens {
return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true); return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true);
} }
let mut verified_email = None;
if send.auth_type() == SendAuthType::Email {
if !CONFIG.mail_enabled() {
return Self::invalid_error(
&format!("Send {send_id} needs email verification, but email is not configured"),
"send_id_invalid",
false,
);
}
let allowed = send.auth_emails().unwrap_or_default();
let Some(email) = email.map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()) else {
return Self::expected_error("Email is required", "email_required");
};
// Like the Bitwarden server, every failure below gets the same answer as "code sent",
// so the link alone does not reveal which emails may open the Send
if !allowed.contains(&email) {
warn!("Send {send_id}, email not allowed, from {}", ip.ip);
return Self::expected_error("Email and OTP are required", "email_and_otp_required");
}
match otp.map(|o| o.trim().to_owned()).filter(|o| !o.is_empty()) {
None => {
issue_send_otp(&send_id, &email);
return Self::expected_error("Email and OTP are required", "email_and_otp_required");
}
Some(code) => {
if !verify_send_otp(&send_id, &email, &code) {
warn!("Send {send_id}, invalid email verification code from {}", ip.ip);
return Self::expected_error("Email and OTP are required", "email_and_otp_required");
}
verified_email = Some(email);
}
}
}
if send.password_hash.is_some() { if send.password_hash.is_some() {
match password { match password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
@ -103,6 +244,12 @@ impl SendTokens {
} }
} }
// Audit trail of who opened an email verified Send. The access itself is counted when the
// content is requested, like upstream: getting a token only checks the limit
if let Some(email) = verified_email {
info!("Send {send_id} opened by {email} from {}", ip.ip);
}
Ok(Self { Ok(Self {
access_claims: generate_send_access_claims(&send_id), access_claims: generate_send_access_claims(&send_id),
}) })
@ -141,3 +288,68 @@ impl<'r> FromRequest<'r> for SendHeaders {
}) })
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn plant(send_id: &SendId, email: &str, code: &str, expires_in: i64) {
let now = Utc::now().timestamp();
SEND_OTPS.lock().unwrap().insert(
(send_id.clone(), email.to_owned()),
SendOtp {
code: Some(code.to_owned()),
created: now,
expires: now + expires_in,
failed_attempts: 0,
},
);
}
#[test]
fn valid_code_opens_once() {
let id = SendId::from("otp-once".to_string());
plant(&id, "a@x.com", "123456", 600);
assert!(verify_send_otp(&id, "a@x.com", "123456"));
assert!(!verify_send_otp(&id, "a@x.com", "123456"), "a code is single use");
}
#[test]
fn code_is_bound_to_send_and_email() {
let id = SendId::from("otp-bound".to_string());
plant(&id, "a@x.com", "123456", 600);
assert!(!verify_send_otp(&id, "b@x.com", "123456"));
assert!(!verify_send_otp(&SendId::from("otp-other".to_string()), "a@x.com", "123456"));
assert!(verify_send_otp(&id, "a@x.com", "123456"));
}
#[test]
fn wrong_codes_burn_the_pending_code() {
let id = SendId::from("otp-burn".to_string());
plant(&id, "a@x.com", "123456", 600);
for _ in 0..CONFIG.email_attempts_limit() {
assert!(!verify_send_otp(&id, "a@x.com", "000000"));
}
assert!(!verify_send_otp(&id, "a@x.com", "123456"), "after the attempts limit the right code is gone too");
}
#[test]
fn burned_code_keeps_the_resend_cooldown() {
let id = SendId::from("otp-cooldown".to_string());
plant(&id, "a@x.com", "123456", 600);
for _ in 0..CONFIG.email_attempts_limit() {
assert!(!verify_send_otp(&id, "a@x.com", "000000"));
}
assert_eq!(reserve_send_otp(&id, "a@x.com"), None, "wrong tries must not skip the cooldown");
// Another recipient of the same Send has its own cooldown
assert!(reserve_send_otp(&id, "b@x.com").is_some());
assert_eq!(reserve_send_otp(&id, "b@x.com"), None);
}
#[test]
fn expired_code_is_refused() {
let id = SendId::from("otp-expired".to_string());
plant(&id, "a@x.com", "123456", -1);
assert!(!verify_send_otp(&id, "a@x.com", "123456"));
}
}

18
src/config.rs

@ -725,6 +725,11 @@ make_config! {
/// Customize the enabled feature flags on the clients |> This is a comma separated list of feature flags to enable. /// Customize the enabled feature flags on the clients |> This is a comma separated list of feature flags to enable.
experimental_client_feature_flags: String, false, def, String::new(); experimental_client_feature_flags: String, false, def, String::new();
/// Minimum client version for temporary item sharing |> Item Sends and the `pm-34203-temporary-item-sharing`
/// and `pm-30110-sdk-sends-api` flags are only served to clients at or above this version. Older clients and
/// the mobile apps can not parse Item Sends, and an unknown Send type fails their whole sync.
item_sharing_min_client_version: String, false, def, "2026.10.0".to_owned();
/// Require new device emails |> When a user logs in an email is required to be sent. /// Require new device emails |> When a user logs in an email is required to be sent.
/// If sending the email fails the login attempt will fail. /// If sending the email fails the login attempt will fail.
require_device_email: bool, true, def, false; require_device_email: bool, true, def, false;
@ -1075,6 +1080,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
println!("[WARNING] {feature_flags_error}"); println!("[WARNING] {feature_flags_error}");
} }
if semver::Version::parse(&cfg.item_sharing_min_client_version).is_err() {
err!("`ITEM_SHARING_MIN_CLIENT_VERSION` must be a version like `2026.10.0`")
}
#[expect(clippy::items_after_statements, reason = "Keep this close to where it is used")] #[expect(clippy::items_after_statements, reason = "Keep this close to where it is used")]
const MAX_FILESIZE_KB: i64 = i64::MAX >> 10; const MAX_FILESIZE_KB: i64 = i64::MAX >> 10;
@ -1439,10 +1448,18 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"pm-34171-card-scanner", "pm-34171-card-scanner",
// Platform Team // Platform Team
"pm-30529-webauthn-related-origins", "pm-30529-webauthn-related-origins",
// Tools Team
// Both are needed for temporary item sharing: Item Sends are only decrypted through the SDK
"pm-30110-sdk-sends-api",
"pm-34203-temporary-item-sharing",
// Vault Team // Vault Team
"pm-32009-new-item-types", "pm-32009-new-item-types",
]; ];
/// Flags that change how clients handle Sends. They are only served to clients at or above
/// `ITEM_SHARING_MIN_CLIENT_VERSION`, see `api::core::sends::client_supports_item_sharing`.
pub const ITEM_SHARING_FEATURE_FLAGS: &[&str] = &["pm-30110-sdk-sends-api", "pm-34203-temporary-item-sharing"];
impl Config { impl Config {
pub async fn load() -> Result<Self, Error> { pub async fn load() -> Result<Self, Error> {
// Loading from env and file // Loading from env and file
@ -1743,6 +1760,7 @@ where
reg!("email/send_2fa_removed_from_org", ".html"); reg!("email/send_2fa_removed_from_org", ".html");
reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_emergency_access_invite", ".html");
reg!("email/send_org_invite", ".html"); reg!("email/send_org_invite", ".html");
reg!("email/send_otp", ".html");
reg!("email/send_single_org_removed_from_org", ".html"); reg!("email/send_single_org_removed_from_org", ".html");
reg!("email/smtp_test", ".html"); reg!("email/smtp_test", ".html");
reg!("email/sso_change_email", ".html"); reg!("email/sso_change_email", ".html");

2
src/db/models/mod.rs

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

193
src/db/models/send.rs

@ -17,7 +17,7 @@ use crate::{
util::{LowerCase, NumberOrString, format_date}, util::{LowerCase, NumberOrString, format_date},
}; };
use super::{OrganizationId, User, UserId}; use super::{CipherId, OrganizationId, User, UserId};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = sends)] #[diesel(table_name = sends)]
@ -55,18 +55,36 @@ pub struct Send {
pub enum SendType { pub enum SendType {
Text = 0, Text = 0,
File = 1, File = 1,
// A shared vault item, encrypted client side as a single blob (temporary item sharing)
Item = 2,
} }
enum SendAuthType { #[derive(Copy, Clone, Debug, PartialEq, Eq, num_derive::FromPrimitive)]
#[allow(dead_code)] pub enum SendAuthType {
// Send requires email OTP verification // Send requires email OTP verification
Email = 0, // Not yet supported by Vaultwarden Email = 0,
// Send requires a password // Send requires a password
Password = 1, Password = 1,
// Send requires no auth // Send requires no auth
None = 2, None = 2,
} }
/// Key inside `sends.data` holding the normalized, comma separated list of emails allowed to
/// access an email verified Send. It lives inside `data` instead of a new column so the feature
/// needs no schema change, and it is stripped before `data` is sent to any client.
const AUTH_EMAILS_KEY: &str = "vwAuthEmails";
/// Normalizes a comma separated email list: trimmed, lowercased, deduplicated, empty entries dropped.
pub fn normalize_emails(emails: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for e in emails.split(',').map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()) {
if !out.contains(&e) {
out.push(e);
}
}
out
}
impl Send { impl Send {
pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self { pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
@ -144,13 +162,76 @@ impl Send {
BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes()) BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes())
} }
pub fn to_json(&self) -> Value { /// The emails allowed to access this Send through email OTP verification, if any.
pub fn auth_emails(&self) -> Option<Vec<String>> {
let data = serde_json::from_str::<Value>(&self.data).ok()?;
let emails = normalize_emails(data.get(AUTH_EMAILS_KEY)?.as_str()?);
(!emails.is_empty()).then_some(emails)
}
/// Stores (or removes, with `None` or an empty list) the emails allowed to access this Send.
/// Setting emails clears the password: the two auth methods are mutually exclusive.
pub fn set_auth_emails(&mut self, emails: Option<&str>) -> EmptyResult {
let mut data = serde_json::from_str::<Value>(&self.data).unwrap_or_else(|_| json!({}));
let Some(obj) = data.as_object_mut() else {
err!("Invalid Send data")
};
let emails = emails.map(normalize_emails).unwrap_or_default();
if emails.is_empty() {
obj.remove(AUTH_EMAILS_KEY);
} else {
obj.insert(AUTH_EMAILS_KEY.to_owned(), Value::String(emails.join(",")));
self.set_password(None);
}
self.data = serde_json::to_string(&data)?;
Ok(())
}
/// The id of the shared vault item, stored unencrypted next to the sealed item (`metadata.itemId`,
/// bitwarden/server#8485). The clients restore the item id from it on decrypt.
pub fn item_id(&self) -> Option<CipherId> {
if self.atype != SendType::Item as i32 {
return None;
}
let data = serde_json::from_str::<Value>(&self.data).ok()?;
let item_id = data.get("metadata")?.get("itemId")?.as_str()?;
Uuid::parse_str(item_id).ok().map(|u| CipherId::from(u.to_string()))
}
/// An Item Send without `metadata` was created before the clients sealed the item as a blob (SDK
/// 0.2.0-main.1073, bitwarden/sdk-internal#1539). No client decrypts it anymore, and listing it to
/// one that does breaks its whole Send list, so it is hidden everywhere until it is purged.
pub fn is_legacy_item(&self) -> bool {
self.atype == SendType::Item as i32 && self.item_id().is_none()
}
pub fn auth_type(&self) -> SendAuthType {
// Item Sends are always email verified, matching the Bitwarden server
if self.atype == SendType::Item as i32 || self.auth_emails().is_some() {
SendAuthType::Email
} else if self.password_hash.is_some() {
SendAuthType::Password
} else {
SendAuthType::None
}
}
/// The `data` column as sent to clients: keys lowercased and the server side auth list removed.
fn client_data(&self) -> Value {
let mut data = serde_json::from_str::<LowerCase<Value>>(&self.data).map(|d| d.data).unwrap_or_default(); let mut data = serde_json::from_str::<LowerCase<Value>>(&self.data).map(|d| d.data).unwrap_or_default();
if let Some(obj) = data.as_object_mut() {
obj.remove(AUTH_EMAILS_KEY);
}
// Mobile clients expect size to be a string instead of a number // Mobile clients expect size to be a string instead of a number
if let Some(size) = data.get("size").and_then(Value::as_i64) { if let Some(size) = data.get("size").and_then(Value::as_i64) {
data["size"] = Value::String(size.to_string()); data["size"] = Value::String(size.to_string());
} }
data
}
pub fn to_json(&self) -> Value {
let data = self.client_data();
json!({ json!({
"id": self.uuid, "id": self.uuid,
@ -161,12 +242,14 @@ impl Send {
"notes": self.notes, "notes": self.notes,
"text": if self.atype == SendType::Text as i32 { Some(&data) } else { None }, "text": if self.atype == SendType::Text as i32 { Some(&data) } else { None },
"file": if self.atype == SendType::File 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, "key": self.akey,
"maxAccessCount": self.max_access_count, "maxAccessCount": self.max_access_count,
"accessCount": self.access_count, "accessCount": self.access_count,
"password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), "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 }, "emails": self.auth_emails().map(|e| e.join(",")),
"authType": self.auth_type() as i32,
"disabled": self.disabled, "disabled": self.disabled,
"hideEmail": self.hide_email.unwrap_or(false), "hideEmail": self.hide_email.unwrap_or(false),
@ -178,20 +261,17 @@ impl Send {
} }
pub async fn to_json_access(&self, conn: &DbConn) -> Value { pub async fn to_json_access(&self, conn: &DbConn) -> Value {
let mut data = serde_json::from_str::<LowerCase<Value>>(&self.data).map(|d| d.data).unwrap_or_default(); let data = self.client_data();
// Mobile clients expect size to be a string instead of a number
if let Some(size) = data.get("size").and_then(Value::as_i64) {
data["size"] = Value::String(size.to_string());
}
json!({ json!({
"id": self.access_id(), "id": self.access_id(),
"type": self.atype, "type": self.atype,
"authType": self.auth_type() as i32,
"name": self.name, "name": self.name,
"text": if self.atype == SendType::Text as i32 { Some(&data) } else { None }, "text": if self.atype == SendType::Text as i32 { Some(&data) } else { None },
"file": if self.atype == SendType::File 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), "expirationDate": self.expiration_date.as_ref().map(format_date),
"creatorIdentifier": self.creator_identifier(conn).await, "creatorIdentifier": self.creator_identifier(conn).await,
@ -397,3 +477,92 @@ impl AsRef<Path> for SendFileId {
Path::new(&self.0) Path::new(&self.0)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn new_send(atype: SendType, data: &str) -> Send {
let deletion = Utc::now().naive_utc() + chrono::TimeDelta::try_days(1).unwrap();
Send::new(atype as i32, "2.name".into(), data.into(), "2.key".into(), deletion)
}
#[test]
fn normalize_emails_trims_lowercases_and_dedups() {
assert_eq!(normalize_emails(" A@x.com, b@Y.com,,a@x.com , "), vec!["a@x.com", "b@y.com"]);
assert!(normalize_emails(" , ").is_empty());
}
#[test]
fn auth_emails_round_trip_and_clear_password() {
let mut send = new_send(SendType::Text, r#"{"text":"2.t","hidden":false}"#);
send.set_password(Some("hash"));
send.set_auth_emails(Some("B@y.com, a@x.com")).unwrap();
assert_eq!(send.auth_emails(), Some(vec!["b@y.com".to_string(), "a@x.com".to_string()]));
assert!(send.password_hash.is_none(), "email and password auth are exclusive");
assert_eq!(send.auth_type(), SendAuthType::Email);
send.set_auth_emails(None).unwrap();
assert_eq!(send.auth_emails(), None);
assert_eq!(send.auth_type(), SendAuthType::None);
assert!(!send.data.contains(AUTH_EMAILS_KEY));
}
#[test]
fn email_list_never_reaches_clients() {
let mut send = new_send(SendType::Text, r#"{"text":"2.t","hidden":false}"#);
send.set_auth_emails(Some("a@x.com")).unwrap();
let json = send.to_json();
assert_eq!(json["emails"], "a@x.com");
assert_eq!(json["authType"], SendAuthType::Email as i32);
assert!(json["text"].get(AUTH_EMAILS_KEY).is_none());
assert_eq!(json["text"]["text"], "2.t");
}
#[test]
fn item_send_json_carries_data_and_is_email_verified() {
let send = new_send(SendType::Item, r#"{"encryptionVersion":1,"data":"{\"id\":\"c\"}"}"#);
let json = send.to_json();
assert_eq!(json["type"], 2);
assert!(json["text"].is_null() && json["file"].is_null());
assert_eq!(json["data"]["encryptionVersion"], 1);
assert_eq!(json["data"]["data"], "{\"id\":\"c\"}");
assert_eq!(send.auth_type(), SendAuthType::Email, "Item Sends are always email verified");
}
#[test]
fn item_id_comes_from_the_metadata_and_marks_the_legacy_format() {
let item_id = "5d4fbf2b-7a36-4b3c-9f2e-1a6d8c0e9b71";
let sealed = new_send(
SendType::Item,
&format!(r#"{{"encryptionVersion":1,"data":"{{}}","metadata":{{"itemId":"{item_id}"}}}}"#),
);
assert_eq!(sealed.item_id().map(|i| i.to_string()), Some(item_id.to_owned()));
assert!(!sealed.is_legacy_item());
let json = sealed.to_json();
assert_eq!(json["data"]["metadata"]["itemId"], item_id, "the owner gets the item id back");
let legacy = new_send(SendType::Item, r#"{"encryptionVersion":1,"data":"{\"id\":\"c\"}"}"#);
assert_eq!(legacy.item_id(), None);
assert!(legacy.is_legacy_item());
let bad_id = new_send(SendType::Item, r#"{"encryptionVersion":1,"data":"{}","metadata":{"itemId":"c"}}"#);
assert!(bad_id.is_legacy_item(), "an item id that is not a UUID is no item id");
let text =
new_send(SendType::Text, r#"{"text":"2.t","metadata":{"itemId":"5d4fbf2b-7a36-4b3c-9f2e-1a6d8c0e9b71"}}"#);
assert_eq!(text.item_id(), None);
assert!(!text.is_legacy_item(), "only Item Sends have a format");
}
#[test]
fn password_auth_type() {
let mut send = new_send(SendType::Text, r#"{"text":"2.t"}"#);
assert_eq!(send.auth_type(), SendAuthType::None);
send.set_password(Some("hash"));
assert_eq!(send.auth_type(), SendAuthType::Password);
}
}

14
src/mail.rs

@ -684,6 +684,20 @@ pub async fn send_protected_action_token(address: &str, token: &str) -> EmptyRes
send_email(address, &subject, body_html, body_text).await send_email(address, &subject, body_html, body_text).await
} }
pub async fn send_send_otp(address: &str, token: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/send_otp",
json!({
"url": CONFIG.domain(),
"img_src": CONFIG._smtp_img_src(),
"token": token,
"expiration": CONFIG.email_expiration_time() / 60,
}),
)?;
send_email(address, &subject, body_html, body_text).await
}
async fn send_with_selected_transport(email: Message) -> EmptyResult { async fn send_with_selected_transport(email: Message) -> EmptyResult {
if CONFIG.use_sendmail() { if CONFIG.use_sendmail() {
match sendmail_transport().send(email).await { match sendmail_transport().send(email).await {

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

@ -0,0 +1,6 @@
Your Send verification code is {{token}}
<!---------------->
Your verification code to view the Send is: {{token}}
It expires in {{expiration}} minutes. If you did not try to open a Send, you can ignore this email.
{{> email/email_footer_text }}

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

@ -0,0 +1,16 @@
Your Send verification code is {{token}}
<!---------------->
{{> 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 verification code to view the Send 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">
It expires in {{expiration}} minutes. If you did not try to open a Send, you can ignore this email.
</td>
</tr>
</table>
{{> email/email_footer }}
Loading…
Cancel
Save