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/ciphers.rs b/src/api/core/ciphers.rs index 46766896..7234c2da 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_to}, +}; pub fn routes() -> Vec { // Note that many routes have an `admin` variant; this seems to be @@ -127,7 +130,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option=2024.12.0").unwrap(); ver_match.matches(&client_version.0) } else { @@ -156,8 +159,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option = 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_to(s, &headers.device, client_version.as_ref())) + .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..b6a737a1 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}; @@ -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 2a604b74..8922aca6 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}, @@ -12,13 +12,16 @@ use serde_json::Value; use crate::{ CONFIG, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, - auth::{Headers, Host, SendHeaders}, + auth::{ClientVersion, Headers, Host, SendHeaders}, config::PathType, db::{ DbConn, DbPool, - models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId}, + models::{ + Cipher, CipherId, Device, OrgPolicy, OrgPolicyType, Send, SendAuthType, SendFileId, SendId, SendType, + UserId, + }, }, - util::{NumberOrString, save_temp_file}, + util::{FeatureFlagFilter, NumberOrString, parse_experimental_client_feature_flags, save_temp_file}, }; const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available"; @@ -77,6 +80,7 @@ pub struct SendData { deletion_date: DateTime, disabled: bool, hide_email: Option, + auth_type: Option, emails: Option, // Data field @@ -84,6 +88,7 @@ pub struct SendData { notes: Option, text: Option, file: Option, + data: Option, file_length: Option, // Used for key rotations @@ -131,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") }; @@ -148,10 +155,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,15 +167,212 @@ 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)?; + enforce_email_auth_for_item_send(&send)?; Ok(send) } +pub const ITEM_SENDS_FEATURE_FLAG: &str = "pm-34203-temporary-item-sharing"; +// Clients can only decrypt (and rotate the keys of) Item Sends through the SDK +const SDK_SENDS_FEATURE_FLAG: &str = "pm-30110-sdk-sends-api"; + +/// Item Sends (sharing a vault item through a temporary link) are opt-in through the client feature flags, +/// both are needed for clients to handle them. +pub fn item_sends_enabled() -> bool { + let flags = parse_experimental_client_feature_flags( + &CONFIG.experimental_client_feature_flags(), + &FeatureFlagFilter::ValidOnly, + ); + flags.contains_key(ITEM_SENDS_FEATURE_FLAG) && flags.contains_key(SDK_SENDS_FEATURE_FLAG) +} + +/// Whether a Send can be accessed by a recipient, Item Sends are hidden while the feature is disabled. +pub fn send_visible(send: &Send) -> bool { + send.atype != SendType::Item as i32 || item_sends_enabled() +} + +/// Clients able to handle Item Sends. The mobile apps and the CLI don't know the type (an unknown Send type +/// breaks the whole Android sync), the other clients support it since 2026.10.0. +pub fn client_supports_item_sends(device: &Device, client_version: Option<&ClientVersion>) -> bool { + !device.is_mobile() && !device.is_cli() && client_version_supports_item_sends(client_version) +} + +pub fn client_version_supports_item_sends(client_version: Option<&ClientVersion>) -> bool { + static MIN_VERSION: LazyLock = + LazyLock::new(|| semver::VersionReq::parse(">=2026.10.0").unwrap()); + client_version.is_some_and(|v| MIN_VERSION.matches(&v.0)) +} + +/// Whether a Send can be shown to its owner on the requesting client. +pub fn send_visible_to(send: &Send, device: &Device, client_version: Option<&ClientVersion>) -> bool { + send.atype != SendType::Item as i32 || (item_sends_enabled() && client_supports_item_sends(device, client_version)) +} + +/// Data of an Item Send. The vault item is encrypted client side into a single opaque blob, the item id +/// is kept in clear so the server can check that the sender is allowed to share it. +/// +/// Upstream: https://github.com/bitwarden/server/blob/main/src/Core/Tools/Models/Data/SendItemData.cs +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct SendItemData { + encryption_version: i32, + data: String, + metadata: SendItemMetadata, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct SendItemMetadata { + item_id: CipherId, +} + +impl SendItemData { + const ENCRYPTION_VERSION: i32 = 1; + const MAX_DATA_LENGTH: usize = 500_000; +} + +fn parse_item_data(data: Option) -> 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 { + 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 { +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().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, @@ -182,8 +382,15 @@ 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 { +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_to(&send, &headers.device, client_version.as_ref()) + { Ok(Json(send.to_json())) } else { err!("Send not found", "Invalid send uuid or does not belong to user") @@ -201,7 +408,10 @@ async fn post_send(data: Json, headers: Headers, conn: DbConn, nt: Not err!("File sends should use /api/sends/file/v2") } - let mut send = create_send(data, headers.user.uuid)?; + let mut send = create_send(data, headers.user.uuid.clone())?; + if let Some(item_id) = stored_item_id(&send) { + enforce_item_shareable(&item_id, &headers.user.uuid, &conn).await?; + } send.save(&conn).await?; nt.send_send_update( UpdateType::SyncSendCreate, @@ -368,11 +578,13 @@ async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> Json let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else { err_code!(SEND_INACCESSIBLE_MSG, 404) }; - if !send.is_accessible() { + if !send.is_accessible() || !send_visible(&send) { err_code!(SEND_INACCESSIBLE_MSG, 404) } // Files are incremented during the download - if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? { + if (send.atype == SendType::Text as i32 || send.atype == SendType::Item as i32) + && !send.register_access(&conn).await? + { err_code!(SEND_INACCESSIBLE_MSG, 404) } process_access(send, conn, nt).await @@ -473,10 +685,6 @@ async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: err!("Send not found", "Send send_id is invalid or does not belong to user") }; - if data.emails.is_some() { - err!("Sends with email verification is not supported"); - } - update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; Ok(Json(send.to_json())) @@ -514,6 +722,13 @@ async fn update_send_from_data( err!("Send data not provided"); }; send.data = data_str; + } else if data.r#type == SendType::Item as i32 { + let item = parse_item_data(data.data)?; + // Only check access to the item when it changes + if stored_item_id(send).as_ref() != Some(&item.metadata.item_id) { + enforce_item_shareable(&item.metadata.item_id, &headers.user.uuid, conn).await?; + } + send.data = serde_json::to_string(&item)?; } send.name = data.name; @@ -528,10 +743,9 @@ async fn update_send_from_data( send.hide_email = data.hide_email; send.disabled = data.disabled; - // Only change the value if it's present - if let Some(password) = data.password { - send.set_password(Some(&password)); - } + apply_send_auth(send, data.auth_type, data.emails.as_deref(), data.password)?; + enforce_mail_enabled_for_email_auth(send)?; + enforce_email_auth_for_item_send(send)?; send.save(conn).await?; if ut != UpdateType::None { @@ -568,6 +782,7 @@ async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt }; send.set_password(None); + send.auth_type = send.effective_auth_type() as i32; send.save(&conn).await?; nt.send_send_update( UpdateType::SyncSendUpdate, @@ -580,3 +795,119 @@ async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt Ok(Json(send.to_json())) } + +#[cfg(test)] +mod tests { + use super::*; + + fn new_send() -> Send { + Send::new( + SendType::Text as i32, + String::from("name"), + String::from("{}"), + String::from("key"), + Utc::now().naive_utc(), + ) + } + + const EMAIL: i32 = SendAuthType::Email as i32; + const PASSWORD: i32 = SendAuthType::Password as i32; + const NONE: i32 = SendAuthType::None as i32; + + #[test] + fn test_normalize_emails() { + assert_eq!(normalize_emails(Some(" A@x.com, ,b@X.com ,")).as_deref(), Some("a@x.com,b@x.com")); + assert_eq!(normalize_emails(Some(" , ")), None); + assert_eq!(normalize_emails(Some("")), None); + assert_eq!(normalize_emails(None), None); + } + + #[test] + fn test_email_auth_requires_an_email() { + let mut send = new_send(); + assert!(apply_send_auth(&mut send, Some(EMAIL), Some(" , "), None).is_err()); + assert!(apply_send_auth(&mut send, Some(EMAIL), None, None).is_err()); + } + + #[test] + fn test_email_auth_clears_password() { + let mut send = new_send(); + apply_send_auth(&mut send, Some(PASSWORD), None, Some(String::from("secret"))).unwrap(); + apply_send_auth(&mut send, Some(EMAIL), Some("A@x.com"), Some(String::from("secret"))).unwrap(); + assert_eq!(send.auth_type, EMAIL); + assert_eq!(send.emails.as_deref(), Some("a@x.com")); + assert!(send.password_hash.is_none()); + } + + #[test] + fn test_password_auth() { + let mut send = new_send(); + assert!(apply_send_auth(&mut send, Some(PASSWORD), None, None).is_err()); + + apply_send_auth(&mut send, Some(PASSWORD), Some("a@x.com"), Some(String::from("secret"))).unwrap(); + assert_eq!(send.auth_type, PASSWORD); + assert!(send.emails.is_none()); + + // Edits don't resend the password, the existing one is kept + apply_send_auth(&mut send, Some(PASSWORD), None, None).unwrap(); + assert_eq!(send.auth_type, PASSWORD); + assert!(send.check_password("secret")); + } + + #[test] + fn test_none_auth_clears_everything() { + let mut send = new_send(); + apply_send_auth(&mut send, Some(EMAIL), Some("a@x.com"), None).unwrap(); + apply_send_auth(&mut send, Some(NONE), Some("a@x.com"), Some(String::from("secret"))).unwrap(); + assert_eq!(send.auth_type, NONE); + assert!(send.emails.is_none()); + assert!(send.password_hash.is_none()); + } + + #[test] + fn test_missing_auth_type_is_inferred() { + let mut send = new_send(); + apply_send_auth(&mut send, None, None, Some(String::from("secret"))).unwrap(); + assert_eq!(send.auth_type, PASSWORD); + + apply_send_auth(&mut send, None, Some("a@x.com"), None).unwrap(); + assert_eq!(send.auth_type, EMAIL); + assert!(send.password_hash.is_none()); + + // Clients without authType don't know about emails, an email protected Send stays protected + apply_send_auth(&mut send, None, None, None).unwrap(); + assert_eq!(send.auth_type, EMAIL); + assert_eq!(send.emails.as_deref(), Some("a@x.com")); + } + + #[test] + fn test_item_send_data_roundtrip() { + let mut send = new_send(); + send.atype = SendType::Item as i32; + send.data = String::from(r#"{"encryptionVersion":1,"data":"blob","metadata":{"itemId":"item-id"}}"#); + assert_eq!(stored_item_id(&send).as_deref().map(String::as_str), Some("item-id")); + + // Other Send types don't have an item + assert!(stored_item_id(&new_send()).is_none()); + } + + #[test] + fn test_item_send_requires_email_auth() { + let mut send = new_send(); + send.atype = SendType::Item as i32; + apply_send_auth(&mut send, Some(PASSWORD), None, Some(String::from("secret"))).unwrap(); + assert!(enforce_email_auth_for_item_send(&send).is_err()); + + apply_send_auth(&mut send, Some(EMAIL), Some("a@x.com"), None).unwrap(); + assert!(enforce_email_auth_for_item_send(&send).is_ok()); + + // Text Sends aren't affected + assert!(enforce_email_auth_for_item_send(&new_send()).is_ok()); + } + + #[test] + fn test_invalid_auth_type() { + let mut send = new_send(); + assert!(apply_send_auth(&mut send, Some(42), None, None).is_err()); + } +} 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..64bf6a14 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 { @@ -85,12 +91,14 @@ impl SendTokens { return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true); } - if !send.is_accessible() { + if !send.is_accessible() || !crate::api::core::send_visible(&send) { return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true); } - if send.password_hash.is_some() { - match password { + // Rely on the stored auth type and fail closed if the Send doesn't hold what it requires + match SendAuthType::from_i32(send.auth_type) { + Some(SendAuthType::Email) => Self::verify_email_otp(&send, email, otp, conn).await?, + Some(SendAuthType::Password) => match password { Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } Some(_) => { return Self::invalid_error( @@ -100,6 +108,14 @@ impl SendTokens { ); } None => return Self::expected_error("Password required", "password_hash_b64_required"), + }, + Some(SendAuthType::None) if send.password_hash.is_none() && send.emails.is_none() => {} + _ => { + return Self::invalid_error( + &format!("Send {send_id}, inconsistent access settings"), + "send_id_invalid", + false, + ); } } @@ -109,6 +125,67 @@ impl SendTokens { } } +impl SendTokens { + /// Email verification: the first request (without `otp`) emails a code to the recipient, the + /// second one exchanges it for an access token. + /// + /// Unlisted emails get the same response as listed ones and no email is sent, so the list of + /// recipients can't be enumerated. + /// + /// Upstream: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs + async fn verify_email_otp(send: &Send, email: Option, 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()); + SendAccessOtp::new(send.uuid.clone(), email.clone(), code.clone()).save(conn).await?; + // Mail in the background, so neither the response time nor a mail failure tells listed + // and unlisted emails apart. On failure the recipient can request a new code after the cooldown. + tokio::spawn(async move { + if let Err(e) = mail::send_send_access_otp(&email, &code).await { + error!("Failed to send Send verification code: {e:#?}"); + } + }); + } + return Self::expected_error("Email and OTP required", "email_and_otp_required"); + }; + + let Some(stored) = stored else { + return Self::expected_error("Email and OTP required", "email_and_otp_required"); + }; + if stored.is_expired() || stored.attempts_exhausted() { + stored.delete(conn).await?; + return Self::expected_error("Email and OTP required", "email_and_otp_required"); + } + if !crypto::ct_eq(&code, &stored.code) { + stored.increment_attempts(conn).await?; + return Self::expected_error("Email and OTP required", "email_and_otp_required"); + } + + // Codes are single use + stored.delete(conn).await?; + Ok(()) + } +} + pub struct SendHeaders { pub send_id: SendId, } diff --git a/src/config.rs b/src/config.rs index a56748f4..8746d399 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1439,6 +1439,9 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "pm-34171-card-scanner", // Platform Team "pm-30529-webauthn-related-origins", + // Tools Team + "pm-30110-sdk-sends-api", + "pm-34203-temporary-item-sharing", // Vault Team "pm-32009-new-item-types", ]; @@ -1736,6 +1739,7 @@ where reg!("email/invite_confirmed", ".html"); reg!("email/new_device_logged_in", ".html"); reg!("email/protected_action", ".html"); + reg!("email/send_access_otp", ".html"); reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_some", ".html"); reg!("email/recover_twofactor", ".html"); 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..7cfc42d0 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,18 +52,21 @@ 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)] pub enum SendType { Text = 0, File = 1, + Item = 2, } -enum SendAuthType { - #[allow(dead_code)] +#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] +pub enum SendAuthType { // Send requires email OTP verification - Email = 0, // Not yet supported by Vaultwarden + Email = 0, // Send requires a password Password = 1, // Send requires no auth @@ -96,6 +102,8 @@ impl Send { disabled: false, hide_email: None, + auth_type: SendAuthType::None as i32, + emails: None, } } @@ -115,6 +123,17 @@ impl Send { } } + /// The protection actually enforced, given the stored emails and password. + pub fn effective_auth_type(&self) -> SendAuthType { + if self.emails.is_some() { + SendAuthType::Email + } else if self.password_hash.is_some() { + SendAuthType::Password + } else { + SendAuthType::None + } + } + pub fn check_password(&self, password: &str) -> bool { match (&self.password_hash, &self.password_salt, self.password_iter) { (Some(hash), Some(salt), Some(iter)) => { @@ -161,12 +180,14 @@ impl Send { "notes": self.notes, "text": if self.atype == SendType::Text as i32 { Some(&data) } else { None }, "file": if self.atype == SendType::File as i32 { Some(&data) } else { None }, + "data": if self.atype == SendType::Item as i32 { Some(&data) } else { None }, "key": self.akey, "maxAccessCount": self.max_access_count, "accessCount": self.access_count, "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), - "authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 }, + "authType": self.auth_type, + "emails": self.emails, "disabled": self.disabled, "hideEmail": self.hide_email.unwrap_or(false), @@ -192,6 +213,7 @@ impl Send { "name": self.name, "text": if self.atype == SendType::Text as i32 { Some(&data) } else { None }, "file": if self.atype == SendType::File as i32 { Some(&data) } else { None }, + "data": if self.atype == SendType::Item as i32 { Some(&data) } else { None }, "expirationDate": self.expiration_date.as_ref().map(format_date), "creatorIdentifier": self.creator_identifier(conn).await, @@ -282,17 +304,20 @@ impl Send { operator.delete_with(&self.uuid).recursive(true).await.ok(); } + SendAccessOtp::delete_all_by_send(&self.uuid, conn).await?; + conn.run(move |conn| { diesel::delete(sends::table.filter(sends::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting send") }) .await } - /// Purge all sends that are past their deletion date. + /// Purge all sends that are past their deletion date, and expired access codes. pub async fn purge(conn: &DbConn) { for send in Self::find_by_past_deletion_date(conn).await { send.delete(conn).await.ok(); } + SendAccessOtp::delete_expired(conn).await.ok(); } pub async fn update_users_revision(&self, conn: &DbConn) -> Vec { @@ -361,6 +386,126 @@ impl Send { } } +/// A one-time code emailed to a recipient of an email verified Send. +#[derive(Identifiable, Queryable, Insertable, AsChangeset)] +#[diesel(table_name = send_access_otps)] +#[diesel(primary_key(send_uuid, email))] +pub struct SendAccessOtp { + pub send_uuid: SendId, + pub email: String, + pub code: String, + pub attempts: i32, + pub creation_date: NaiveDateTime, + pub expiration_date: NaiveDateTime, +} + +impl SendAccessOtp { + /// Minimum time before a new code can be requested, so the endpoint can't be used to spam a + /// recipient or keep invalidating the code they are about to type. + const RESEND_COOLDOWN_SECONDS: i64 = 60; + + pub fn new(send_uuid: SendId, email: String, code: String) -> Self { + let now = Utc::now().naive_utc(); + Self { + send_uuid, + email, + code, + attempts: 0, + creation_date: now, + // Same settings as the email 2FA codes + expiration_date: now + TimeDelta::seconds(CONFIG.email_expiration_time().cast_signed()), + } + } + + /// Wrong guesses allowed before a code is discarded. + pub fn attempts_exhausted(&self) -> bool { + u64::try_from(self.attempts).unwrap_or(u64::MAX) >= CONFIG.email_attempts_limit() + } + + pub fn is_expired(&self) -> bool { + Utc::now().naive_utc() >= self.expiration_date + } + + pub fn can_resend(&self) -> bool { + self.is_expired() + || Utc::now().naive_utc() >= self.creation_date + TimeDelta::seconds(Self::RESEND_COOLDOWN_SECONDS) + } + + /// Stores the code, replacing any previous one for the same Send and email. + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + conn.run(move |conn| { + conn.transaction(|conn| { + diesel::delete( + send_access_otps::table + .filter(send_access_otps::send_uuid.eq(&self.send_uuid)) + .filter(send_access_otps::email.eq(&self.email)), + ) + .execute(conn)?; + diesel::insert_into(send_access_otps::table).values(self).execute(conn) + }) + .map_res("Error saving send access otp") + }) + .await + } + + pub async fn increment_attempts(&self, conn: &DbConn) -> EmptyResult { + conn.run(move |conn| { + diesel::update( + send_access_otps::table + .filter(send_access_otps::send_uuid.eq(&self.send_uuid)) + .filter(send_access_otps::email.eq(&self.email)), + ) + .set(send_access_otps::attempts.eq(send_access_otps::attempts + 1)) + .execute(conn) + .map_res("Error updating send access otp") + }) + .await + } + + pub async fn delete(&self, conn: &DbConn) -> EmptyResult { + conn.run(move |conn| { + diesel::delete( + send_access_otps::table + .filter(send_access_otps::send_uuid.eq(&self.send_uuid)) + .filter(send_access_otps::email.eq(&self.email)), + ) + .execute(conn) + .map_res("Error deleting send access otp") + }) + .await + } + + pub async fn delete_all_by_send(send_uuid: &SendId, conn: &DbConn) -> EmptyResult { + conn.run(move |conn| { + diesel::delete(send_access_otps::table.filter(send_access_otps::send_uuid.eq(send_uuid))) + .execute(conn) + .map_res("Error deleting send access otps") + }) + .await + } + + pub async fn delete_expired(conn: &DbConn) -> EmptyResult { + let now = Utc::now().naive_utc(); + conn.run(move |conn| { + diesel::delete(send_access_otps::table.filter(send_access_otps::expiration_date.le(now))) + .execute(conn) + .map_res("Error deleting expired send access otps") + }) + .await + } + + pub async fn find(send_uuid: &SendId, email: &str, conn: &DbConn) -> Option { + 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..50a62255 --- /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. +{{> 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..ffbe529d --- /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. +
+{{> email/email_footer }}