From 024e3a66894d77433640d546e2923c3f206fe6c4 Mon Sep 17 00:00:00 2001 From: ICantThinkOfAName-tech Date: Fri, 16 Oct 2026 21:02:21 +0200 Subject: [PATCH] Add Item Sends (temporary vault item sharing) Support the Item Send type used by the Bitwarden clients (2026.10+) to share a single vault item through a temporary link. - New `Item` Send type; the item is encrypted client side into one opaque blob, stored with its item id in clear - Item Sends require email verification - Only items the user owns, or organization items they can edit and see the password of, can be shared; deleted, archived and SSH key items can't. Access is checked on creation and when the item changes - Accesses are counted like Text Sends, so one-time links work - Opt-in through the `pm-34203-temporary-item-sharing` client feature flag; while disabled, Item Sends can't be created and existing ones are hidden from owners and recipients Co-Authored-By: Claude Opus 5.5 --- src/api/core/ciphers.rs | 13 +++- src/api/core/mod.rs | 2 +- src/api/core/sends.rs | 155 ++++++++++++++++++++++++++++++++++++++-- src/auth/send.rs | 2 +- src/config.rs | 2 + src/db/models/send.rs | 3 + 6 files changed, 165 insertions(+), 12 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 46766896..75c69a68 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -29,7 +29,10 @@ use crate::{ util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, }; -use super::{folders::FolderData, sends::SIZE_525_MB}; +use super::{ + folders::FolderData, + sends::{SIZE_525_MB, send_visible}, +}; pub fn routes() -> Vec { // Note that many routes have an `admin` variant; this seems to be @@ -156,8 +159,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option = Folder::find_by_user(&headers.user.uuid, &conn).await.iter().map(Folder::to_json).collect(); - let sends_json: Vec = - Send::find_by_user(&headers.user.uuid, &conn).await.iter().map(Send::to_json).collect(); + let sends_json: Vec = Send::find_by_user(&headers.user.uuid, &conn) + .await + .iter() + .filter(|s| send_visible(s)) + .map(Send::to_json) + .collect(); let policies_json: Vec = OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect(); diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index e6a184dd..f04b05fc 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -13,7 +13,7 @@ pub use accounts::purge_auth_requests; pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers}; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use events::{event_cleanup_job, log_event, log_user_event}; -pub use sends::purge_sends; +pub use sends::{purge_sends, send_visible}; use reqwest::Method; use rocket::{Catcher, Route, serde::json::Json, serde::json::Value}; diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 192cc52c..2e2381ed 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -16,9 +16,12 @@ use crate::{ config::PathType, db::{ DbConn, DbPool, - models::{Device, OrgPolicy, OrgPolicyType, Send, SendAuthType, SendFileId, SendId, SendType, UserId}, + models::{ + Cipher, CipherId, Device, OrgPolicy, OrgPolicyType, Send, SendAuthType, SendFileId, SendId, SendType, + UserId, + }, }, - util::{NumberOrString, save_temp_file}, + util::{FeatureFlagFilter, NumberOrString, parse_experimental_client_feature_flags, save_temp_file}, }; const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available"; @@ -85,6 +88,7 @@ pub struct SendData { notes: Option, text: Option, file: Option, + data: Option, file_length: Option, // Used for key rotations @@ -132,6 +136,8 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { data.text } else if data.r#type == SendType::File as i32 { data.file + } else if data.r#type == SendType::Item as i32 { + Some(serde_json::to_value(parse_item_data(data.data)?)?) } else { err!("Invalid Send type") }; @@ -163,10 +169,105 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { apply_send_auth(&mut send, data.auth_type, data.emails.as_deref(), data.password)?; enforce_mail_enabled_for_email_auth(&send)?; + enforce_email_auth_for_item_send(&send)?; Ok(send) } +const ITEM_SENDS_FEATURE_FLAG: &str = "pm-34203-temporary-item-sharing"; + +/// Item Sends (sharing a vault item through a temporary link) are opt-in through the client feature flag. +pub fn item_sends_enabled() -> bool { + parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly) + .contains_key(ITEM_SENDS_FEATURE_FLAG) +} + +/// Whether a Send can be shown to its owner or a recipient, Item Sends are hidden while the feature is disabled. +pub fn send_visible(send: &Send) -> bool { + send.atype != SendType::Item as i32 || item_sends_enabled() +} + +/// Data of an Item Send. The vault item is encrypted client side into a single opaque blob, the item id +/// is kept in clear so the server can check that the sender is allowed to share it. +/// +/// Upstream: https://github.com/bitwarden/server/blob/main/src/Core/Tools/Models/Data/SendItemData.cs +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct SendItemData { + encryption_version: i32, + data: String, + metadata: SendItemMetadata, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct SendItemMetadata { + item_id: CipherId, +} + +impl SendItemData { + const ENCRYPTION_VERSION: i32 = 1; + const MAX_DATA_LENGTH: usize = 500_000; +} + +fn parse_item_data(data: Option) -> ApiResult { + if !item_sends_enabled() { + err!("Item Sends are not enabled on this server") + } + let Some(data) = data else { + err!("Send data not provided") + }; + let Ok(item) = serde_json::from_value::(data) else { + err!("Invalid Item Send data") + }; + if item.encryption_version != SendItemData::ENCRYPTION_VERSION { + err!("Unsupported Item Send encryption version") + } + if item.data.is_empty() || item.data.len() > SendItemData::MAX_DATA_LENGTH { + err!("Invalid Item Send data") + } + Ok(item) +} + +fn stored_item_id(send: &Send) -> Option { + serde_json::from_str::(&send.data).ok().map(|d| d.metadata.item_id) +} + +fn enforce_email_auth_for_item_send(send: &Send) -> EmptyResult { + if send.atype == SendType::Item as i32 && send.auth_type != SendAuthType::Email as i32 { + err!("Item Sends require email verification") + } + Ok(()) +} + +/// A user can only share an item they own, or an organization item they can edit and see the password of. +/// Deleted, archived and SSH key items can't be shared. +/// +/// Upstream: https://github.com/bitwarden/server/blob/main/src/Core/Tools/SendFeatures/Services/SendValidationService.cs +async fn enforce_item_shareable(item_id: &CipherId, user_id: &UserId, conn: &DbConn) -> EmptyResult { + const SSH_KEY_TYPE: i32 = 5; + + let Some(cipher) = Cipher::find_by_uuid(item_id, conn).await else { + err!("Item not found.") + }; + let can_share = if cipher.user_uuid.as_ref() == Some(user_id) { + true + } else if cipher.organization_uuid.is_some() { + // (read_only, hide_passwords, manage) + matches!(cipher.get_access_restrictions(user_id, None, conn).await, Some((false, false, _))) + } else { + false + }; + if !can_share + || cipher.deleted_at.is_some() + || cipher.atype == SSH_KEY_TYPE + || cipher.get_archived_at(user_id, conn).await.is_some() + { + err!("Item not found.") + } + Ok(()) +} + /// Splits a comma separated list of emails, trimming and lowercasing each entry and dropping empty ones. /// Returns `None` when no address is left. fn normalize_emails(emails: Option<&str>) -> Option { @@ -243,7 +344,7 @@ fn enforce_mail_enabled_for_email_auth(send: &Send) -> EmptyResult { #[get("/sends")] async fn get_sends(headers: Headers, conn: DbConn) -> Json { let sends = Send::find_by_user(&headers.user.uuid, &conn); - let sends_json: Vec = sends.await.iter().map(Send::to_json).collect(); + let sends_json: Vec = sends.await.iter().filter(|s| send_visible(s)).map(Send::to_json).collect(); Json(json!({ "data": sends_json, @@ -254,7 +355,9 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json { #[get("/sends/")] async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult { - if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await { + if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await + && send_visible(&send) + { Ok(Json(send.to_json())) } else { err!("Send not found", "Invalid send uuid or does not belong to user") @@ -272,7 +375,10 @@ async fn post_send(data: Json, headers: Headers, conn: DbConn, nt: Not err!("File sends should use /api/sends/file/v2") } - let mut send = create_send(data, headers.user.uuid)?; + let mut send = create_send(data, headers.user.uuid.clone())?; + if let Some(item_id) = stored_item_id(&send) { + enforce_item_shareable(&item_id, &headers.user.uuid, &conn).await?; + } send.save(&conn).await?; nt.send_send_update( UpdateType::SyncSendCreate, @@ -439,11 +545,13 @@ async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> Json let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else { err_code!(SEND_INACCESSIBLE_MSG, 404) }; - if !send.is_accessible() { + if !send.is_accessible() || !send_visible(&send) { err_code!(SEND_INACCESSIBLE_MSG, 404) } // Files are incremented during the download - if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? { + if (send.atype == SendType::Text as i32 || send.atype == SendType::Item as i32) + && !send.register_access(&conn).await? + { err_code!(SEND_INACCESSIBLE_MSG, 404) } process_access(send, conn, nt).await @@ -581,6 +689,13 @@ async fn update_send_from_data( err!("Send data not provided"); }; send.data = data_str; + } else if data.r#type == SendType::Item as i32 { + let item = parse_item_data(data.data)?; + // Only check access to the item when it changes + if stored_item_id(send).as_ref() != Some(&item.metadata.item_id) { + enforce_item_shareable(&item.metadata.item_id, &headers.user.uuid, conn).await?; + } + send.data = serde_json::to_string(&item)?; } send.name = data.name; @@ -597,6 +712,7 @@ async fn update_send_from_data( apply_send_auth(send, data.auth_type, data.emails.as_deref(), data.password)?; enforce_mail_enabled_for_email_auth(send)?; + enforce_email_auth_for_item_send(send)?; send.save(conn).await?; if ut != UpdateType::None { @@ -731,6 +847,31 @@ mod tests { assert_eq!(send.emails.as_deref(), Some("a@x.com")); } + #[test] + fn test_item_send_data_roundtrip() { + let mut send = new_send(); + send.atype = SendType::Item as i32; + send.data = String::from(r#"{"encryptionVersion":1,"data":"blob","metadata":{"itemId":"item-id"}}"#); + assert_eq!(stored_item_id(&send).as_deref().map(String::as_str), Some("item-id")); + + // Other Send types don't have an item + assert!(stored_item_id(&new_send()).is_none()); + } + + #[test] + fn test_item_send_requires_email_auth() { + let mut send = new_send(); + send.atype = SendType::Item as i32; + apply_send_auth(&mut send, Some(PASSWORD), None, Some(String::from("secret"))).unwrap(); + assert!(enforce_email_auth_for_item_send(&send).is_err()); + + apply_send_auth(&mut send, Some(EMAIL), Some("a@x.com"), None).unwrap(); + assert!(enforce_email_auth_for_item_send(&send).is_ok()); + + // Text Sends aren't affected + assert!(enforce_email_auth_for_item_send(&new_send()).is_ok()); + } + #[test] fn test_invalid_auth_type() { let mut send = new_send(); diff --git a/src/auth/send.rs b/src/auth/send.rs index 29c8e976..0b57f38a 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -91,7 +91,7 @@ impl SendTokens { return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true); } - if !send.is_accessible() { + if !send.is_accessible() || !crate::api::core::send_visible(&send) { return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true); } diff --git a/src/config.rs b/src/config.rs index 92f7ff6c..014fef60 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1439,6 +1439,8 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "pm-34171-card-scanner", // Platform Team "pm-30529-webauthn-related-origins", + // Tools Team + "pm-34203-temporary-item-sharing", // Vault Team "pm-32009-new-item-types", ]; diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 81b3a2ae..736981de 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -60,6 +60,7 @@ pub struct Send { pub enum SendType { Text = 0, File = 1, + Item = 2, } #[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] @@ -179,6 +180,7 @@ impl Send { "notes": self.notes, "text": if self.atype == SendType::Text as i32 { Some(&data) } else { None }, "file": if self.atype == SendType::File as i32 { Some(&data) } else { None }, + "data": if self.atype == SendType::Item as i32 { Some(&data) } else { None }, "key": self.akey, "maxAccessCount": self.max_access_count, @@ -211,6 +213,7 @@ impl Send { "name": self.name, "text": if self.atype == SendType::Text as i32 { Some(&data) } else { None }, "file": if self.atype == SendType::File as i32 { Some(&data) } else { None }, + "data": if self.atype == SendType::Item as i32 { Some(&data) } else { None }, "expirationDate": self.expiration_date.as_ref().map(format_date), "creatorIdentifier": self.creator_identifier(conn).await,