diff --git a/.env.template b/.env.template index 272c7738..df324cf0 100644 --- a/.env.template +++ b/.env.template @@ -403,8 +403,15 @@ ## - "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) ## - "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= +## 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. ## If sending the email fails the login attempt will fail!! # REQUIRE_DEVICE_EMAIL=false diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 46766896..905cee3b 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -23,7 +23,7 @@ use crate::{ models::{ Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, 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}, @@ -127,7 +127,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option=2024.12.0").unwrap(); ver_match.matches(&client_version.0) } else { @@ -156,8 +156,14 @@ 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(); + // Item Sends fail the whole sync of clients that don't know them (the Android app has no fallback) + let show_item_sends = api::core::sends::client_supports_item_sends(headers.device.atype, client_version.as_ref()); + let sends_json: Vec = 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 = 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..b485612f 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -21,7 +21,7 @@ use rocket::{Catcher, Route, serde::json::Json, serde::json::Value}; use crate::{ CONFIG, api::{EmptyResult, JsonResult, Notify, UpdateType}, - auth::Headers, + auth::{Headers, MaybeClientVersion}, db::{ DbConn, models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, @@ -208,7 +208,7 @@ fn get_api_webauthn(_headers: Headers) -> Json { } #[get("/config")] -fn config() -> Json { +fn config(client_version: MaybeClientVersion) -> 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 +220,13 @@ fn config() -> Json { &FeatureFlagFilter::ValidOnly, ); 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!({ // 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..d0be996d 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,11 +12,14 @@ 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, DeviceType, OrgPolicy, OrgPolicyType, Send, SendAuthType, SendFileId, SendId, + SendType, UserId, normalize_emails, + }, }, util::{NumberOrString, save_temp_file}, }; @@ -70,6 +73,7 @@ pub async fn purge_sends(pool: DbPool) { #[serde(rename_all = "camelCase")] pub struct SendData { r#type: i32, + auth_type: Option, pub key: String, password: Option, max_access_count: Option, @@ -84,6 +88,8 @@ pub struct SendData { notes: Option, text: Option, file: Option, + // Item Sends: `{ encryptionVersion, data }`, the whole shared item encrypted client side + data: Option, file_length: Option, // Used for key rotations @@ -126,20 +132,191 @@ async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, c Ok(()) } -fn create_send(data: SendData, user_id: UserId) -> ApiResult { - let data_val = if data.r#type == SendType::Text as i32 { - data.text - } else if data.r#type == SendType::File as i32 { - data.file - } else { - err!("Invalid Send type") +/// 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) +} - let data_str = if let Some(mut d) = data_val { - d.as_object_mut().and_then(|o| o.remove("response")); - serde_json::to_string(&d)? - } else { - err!("Send data not provided"); +/// 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 { + 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, + 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, + 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 { + 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 + } else { + data.file + }; + let Some(mut d) = data_val else { + err!("Send data not provided"); + }; + d.as_object_mut().and_then(|o| o.remove("response")); + serde_json::to_string(&d)? + } + 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() { @@ -148,10 +325,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 +337,22 @@ 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()); + // 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) } #[get("/sends")] -async fn get_sends(headers: Headers, conn: DbConn) -> Json { - let sends = Send::find_by_user(&headers.user.uuid, &conn); - let sends_json: Vec = sends.await.iter().map(Send::to_json).collect(); +async fn get_sends(headers: Headers, client_version: Option, conn: DbConn) -> Json { + let show_items = client_supports_item_sends(headers.device.atype, client_version.as_ref()); + let sends = Send::find_by_user(&headers.user.uuid, &conn).await; + let sends_json: Vec = sends + .iter() + .filter(|s| s.atype != SendType::Item as i32 || (show_items && !s.is_legacy_item())) + .map(Send::to_json) + .collect(); Json(json!({ "data": sends_json, @@ -182,11 +362,21 @@ 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 { - Ok(Json(send.to_json())) - } else { - err!("Send not found", "Invalid send uuid or does not belong to user") +async fn get_send( + send_id: SendId, + headers: Headers, + client_version: Option, + 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())) + } + _ => err!("Send not found", "Invalid send uuid or does not belong to user"), } } @@ -201,7 +391,8 @@ 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())?; + validate_item_send(&send, None, &headers.user.uuid, &conn).await?; send.save(&conn).await?; nt.send_send_update( 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 { 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) } - // Files are incremented during the download - if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? { + // Files are incremented during the download, text and item Sends here + if send.atype != SendType::File as i32 && !send.register_access(&conn).await? { err_code!(SEND_INACCESSIBLE_MSG, 404) } process_access(send, conn, nt).await @@ -473,10 +664,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())) @@ -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, - // 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 { let data_str = if let Some(mut d) = data.text { 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"); }; 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; @@ -528,10 +726,8 @@ async fn update_send_from_data( send.hide_email = data.hide_email; send.disabled = data.disabled; - // Only change the value if it's present - if let Some(password) = data.password { - send.set_password(Some(&password)); - } + apply_send_auth(send, data.auth_type, data.password.as_deref(), data.emails.as_deref(), current_emails.as_deref())?; + validate_item_send(send, stored_item_id, &headers.user.uuid, conn).await?; send.save(conn).await?; 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())) } + +#[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::(&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::(&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")))); + } +} diff --git a/src/api/identity.rs b/src/api/identity.rs index b61258cf..a36bcabc 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,9 @@ struct ConnectData { // Needed for send access send_id: Option, password_hash_b64: Option, + // Needed for email verified send access + email: Option, + otp: Option, } fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { if value.is_none() { diff --git a/src/auth.rs b/src/auth.rs index 57720382..ce36f35f 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1234,6 +1234,24 @@ impl<'r> FromRequest<'r> for ClientVersion { } } +/// Like `Option`, 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); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for MaybeClientVersion { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> Outcome { + 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)] #[serde(rename_all = "lowercase")] pub enum AuthMethod { diff --git a/src/auth/send.rs b/src/auth/send.rs index 518b7098..f3cffc1d 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -1,18 +1,114 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + use chrono::{TimeDelta, Utc}; use rocket::request::{FromRequest, Outcome, Request}; use crate::{ + CONFIG, api::ApiResult, auth, auth::{BasicJwtClaims, ClientIp}, + crypto, db::{ DbConn, - models::{Send, SendId}, + models::{Send, SendAuthType, SendId}, }, 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, + 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>> = 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 { + 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 { let time_now = Utc::now(); BasicJwtClaims { @@ -68,6 +164,8 @@ impl SendTokens { pub async fn generate_tokens( access_id: &str, password: Option, + email: Option, + otp: Option, ip: &ClientIp, conn: &DbConn, ) -> ApiResult { @@ -79,6 +177,15 @@ impl SendTokens { 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 && 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); } + 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() { match password { 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 { 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")); + } +} diff --git a/src/config.rs b/src/config.rs index a56748f4..83a14ce9 100644 --- a/src/config.rs +++ b/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. 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. /// If sending the email fails the login attempt will fail. 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}"); } + 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")] const MAX_FILESIZE_KB: i64 = i64::MAX >> 10; @@ -1439,10 +1448,18 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "pm-34171-card-scanner", // Platform Team "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 "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 { pub async fn load() -> Result { // Loading from env and file @@ -1743,6 +1760,7 @@ where reg!("email/send_2fa_removed_from_org", ".html"); reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_org_invite", ".html"); + reg!("email/send_otp", ".html"); reg!("email/send_single_org_removed_from_org", ".html"); reg!("email/smtp_test", ".html"); reg!("email/sso_change_email", ".html"); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..384b9426 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, SendAuthType, SendFileId, SendId, SendType, normalize_emails}; 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..61056af1 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -17,7 +17,7 @@ use crate::{ util::{LowerCase, NumberOrString, format_date}, }; -use super::{OrganizationId, User, UserId}; +use super::{CipherId, OrganizationId, User, UserId}; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = sends)] @@ -55,18 +55,36 @@ pub struct Send { pub enum SendType { Text = 0, File = 1, + // A shared vault item, encrypted client side as a single blob (temporary item sharing) + Item = 2, } -enum SendAuthType { - #[allow(dead_code)] +#[derive(Copy, Clone, Debug, 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 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 { + let mut out: Vec = 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 { pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self { 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()) } - 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> { + let data = serde_json::from_str::(&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::(&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 { + if self.atype != SendType::Item as i32 { + return None; + } + let data = serde_json::from_str::(&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::>(&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 if let Some(size) = data.get("size").and_then(Value::as_i64) { data["size"] = Value::String(size.to_string()); } + data + } + + pub fn to_json(&self) -> Value { + let data = self.client_data(); json!({ "id": self.uuid, @@ -161,12 +242,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 }, + "emails": self.auth_emails().map(|e| e.join(",")), + "authType": self.auth_type() as i32, "disabled": self.disabled, "hideEmail": self.hide_email.unwrap_or(false), @@ -178,20 +261,17 @@ impl Send { } pub async fn to_json_access(&self, conn: &DbConn) -> Value { - let mut data = serde_json::from_str::>(&self.data).map(|d| d.data).unwrap_or_default(); - - // 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()); - } + let data = self.client_data(); json!({ "id": self.access_id(), "type": self.atype, + "authType": self.auth_type() as i32, "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, @@ -397,3 +477,92 @@ impl AsRef for SendFileId { 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); + } +} diff --git a/src/mail.rs b/src/mail.rs index 2efb8d70..7ea15aba 100644 --- a/src/mail.rs +++ b/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 } +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 { if CONFIG.use_sendmail() { match sendmail_transport().send(email).await { diff --git a/src/static/templates/email/send_otp.hbs b/src/static/templates/email/send_otp.hbs new file mode 100644 index 00000000..7bca138e --- /dev/null +++ b/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 }} diff --git a/src/static/templates/email/send_otp.html.hbs b/src/static/templates/email/send_otp.html.hbs new file mode 100644 index 00000000..5493bed9 --- /dev/null +++ b/src/static/templates/email/send_otp.html.hbs @@ -0,0 +1,16 @@ +Your Send verification code is {{token}} + +{{> email/email_header }} + + + + + + + +
+ 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 }}