Compare commits

...
Author SHA1 Message Date
Daniel García a2e285248e Implement V2 key rotation support 2026-10-07 23:38:51 +02:00
Daniel García 274ee69db3 Implement V2 registration support 2026-10-07 23:38:51 +02:00
Daniel García 46f4ec83df Support blob-encrypted ciphers and match upstream's key id checks 2026-10-07 23:38:50 +02:00
Pier Carlo Cadoppi df2cd3c869 Add biometrics-sdk-ipc feature flag (#7813) 2026-10-07 19:20:23 +02:00
Tom 0e93d15b73 Add Windows desktop autotype support (#7808) 2026-10-07 19:20:04 +02:00
will-lottkowitz-prismandDaniel García d1cbd027cd Add revision_date to org policies (#7571)
* Add revision_date to org policies

Org policy JSON responses never included a revisionDate field, unlike
every other synced entity. The official Bitwarden server always sets
it, and current official clients (recently migrated to the WASM SDK's
typed PolicyView) now assume it is always a valid date string. Its
absence deserializes to undefined client-side, which then throws
(RangeError: Invalid time value) when the client formats it, breaking
the whole policy sync pipeline and silently emptying the vault view in
the browser and desktop apps.

Adds a revision_date column (mirroring the existing pattern used by
Send), stamped on creation and bumped on every save.

* Fix migration correctness for org policy revision_date

Caught by review: SQLite rejects non-constant defaults in ALTER TABLE
ADD COLUMN, so the sqlite migration would fail outright on every
existing installation (vaultwarden's default backend) rather than the
postgresql setup this was developed against.

- sqlite/mysql: add the column with a constant placeholder default,
  then backfill via UPDATE, since neither allows a volatile default
  in ALTER TABLE ADD COLUMN (mysql does allow it, but a constant
  keeps the three backends' migrations symmetric).
- mysql: use DATETIME instead of TIMESTAMP, matching this repo's
  existing revision_date columns, and backfill with UTC_TIMESTAMP()
  instead of a session-timezone-dependent CURRENT_TIMESTAMP.
- postgresql: backfill with `now() AT TIME ZONE 'utc'` instead of a
  DEFAULT now(), since assigning timestamptz now() into this naive
  TIMESTAMP column would otherwise cast through the server's TimeZone
  GUC and store local wall-clock instead of UTC on non-UTC servers.
- mysql/postgresql: add a real down.sql (DROP COLUMN) instead of
  leaving it empty.

* Move org policy revision_date migration after the latest one

---------

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-07 19:19:26 +02:00
44 changed files with 1813 additions and 408 deletions

No files matched your search

+4
View File
@@ -394,11 +394,15 @@
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
## - "pm-30529-webauthn-related-origins":
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
## - "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)
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent.
+2 -3
View File
@@ -14,9 +14,8 @@ extend-ignore-re = [
# In SMTP it's called HELO, so ignore it
"(?i)helo_name",
"Server name sent during.+HELO",
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
"COSEKey",
"COSEAlgorithm",
# COSE Is short for CBOR Object Signing and Encryption
"(?i)cose",
# Ignore this specific string as it's valid
"Ensure they are valid OTPs",
# This word is misspelled upstream
+1 -1
View File
@@ -107,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
# A safe, extensible ORM and Query builder
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] }
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [
@@ -0,0 +1 @@
ALTER TABLE org_policies DROP COLUMN revision_date;
@@ -0,0 +1,7 @@
-- DATETIME (not TIMESTAMP) to match this repo's convention for revision_date
-- columns elsewhere, and to avoid MySQL's implicit session-timezone
-- conversion and 2038 range limit on TIMESTAMP.
ALTER TABLE org_policies
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = UTC_TIMESTAMP();
Whitespace-only changes.
@@ -0,0 +1,3 @@
-- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the
-- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT.
ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL;
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,14 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE
);
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
@@ -0,0 +1 @@
ALTER TABLE org_policies DROP COLUMN revision_date;
@@ -0,0 +1,9 @@
-- Backfill via `now() AT TIME ZONE 'utc'` rather than a DEFAULT of now():
-- assigning timestamptz now() into a naive TIMESTAMP column casts through
-- the server's TimeZone GUC, so a DEFAULT now() would store local wall-clock
-- instead of UTC on non-UTC servers, unlike every other naive-UTC timestamp
-- column in this schema.
ALTER TABLE org_policies
ADD COLUMN revision_date TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = (now() AT TIME ZONE 'utc');
Whitespace-only changes.
@@ -0,0 +1 @@
-- TEXT has no size limit here, only MySQL needed the change
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
Whitespace-only changes.
@@ -0,0 +1,6 @@
-- SQLite forbids non-constant defaults in ALTER TABLE ... ADD COLUMN, so add
-- the column with a constant placeholder and backfill separately.
ALTER TABLE org_policies
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = CURRENT_TIMESTAMP;
Whitespace-only changes.
@@ -0,0 +1 @@
-- TEXT has no size limit here, only MySQL needed the change
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
+943 -170
View File
File diff suppressed because it is too large. Load diff
+273 -84
View File
@@ -23,7 +23,8 @@ 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, User, UserId,
is_data_blob_encrypted,
},
},
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@@ -189,12 +190,22 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"masterKeyEncryptedUserKey": headers.user.akey,
"masterKeyWrappedUserKey": headers.user.akey,
"salt": headers.user.email
"salt": headers.user.master_password_salt(),
"containedKeyId": headers.user.key_id,
})
} else {
Value::Null
};
// Upstream omits these when unset rather than sending null
let mut user_decryption = json!({ "masterPasswordUnlock": master_password_unlock });
if let Some(key_id) = &headers.user.key_id {
user_decryption["userKeyId"] = json!(key_id);
}
if let Some(v2_upgrade_token) = headers.user.v2_upgrade_token_json() {
user_decryption["v2UpgradeToken"] = v2_upgrade_token;
}
Ok(Json(json!({
"profile": user_json,
"folders": folders_json,
@@ -204,10 +215,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"ciphers": ciphers_json,
"domains": domains_json,
"sends": sends_json,
"userDecryption": {
"masterPasswordUnlock": master_password_unlock,
"userKeyId": headers.user.key_id,
},
"userDecryption": user_decryption,
"object": "sync"
})))
}
@@ -269,7 +277,8 @@ pub struct CipherData {
key: Option<String>,
pub encrypted_for: UserId, // Added in web-v2025.6.0
// Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent
pub encrypted_for: Option<UserId>,
// Added in web-v2025.8.1, Optional for compat
pub encrypted_by_key_id: Option<KeyId>,
@@ -284,7 +293,8 @@ pub struct CipherData {
Passport = 8
*/
pub r#type: i32,
pub name: String,
// Absent on a blob-encrypted cipher, whose name is sealed inside `data`
pub name: Option<String>,
pub notes: Option<String>,
fields: Option<Value>,
@@ -298,6 +308,9 @@ pub struct CipherData {
drivers_license: Option<Value>,
passport: Option<Value>,
// The sealed blob of a v2 account's cipher, which replaces all of the fields above
data: Option<String>,
favorite: Option<bool>,
reprompt: Option<i32>,
@@ -319,6 +332,98 @@ pub struct CipherData {
archived_date: Option<String>,
}
/// A field of a [`CipherData`] that fails upstream's model validation.
#[derive(Debug)]
pub struct CipherValidationError {
pub field: &'static str,
pub message: String,
}
impl From<CipherValidationError> for crate::Error {
fn from(e: CipherValidationError) -> Self {
Self::new_msg(e.message)
}
}
/// The cipher must have been encrypted for the acting user. Only checked when the client sends the field.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1857-L1870>
fn validate_encrypted_for_user(data: &CipherData, user_id: &UserId) -> EmptyResult {
if data.encrypted_for.as_ref().is_some_and(|encrypted_for| encrypted_for != user_id) {
err!("Cipher was not encrypted for the current user. Please try again.")
}
Ok(())
}
/// [`validate_encrypted_for_user`], plus the key id of a user-owned cipher, when both ids are known.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1886-L1911>
fn validate_encrypted_by_user(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult {
validate_encrypted_for_user(data, &user.uuid)?;
if !is_org_cipher
&& let (Some(cipher_key_id), Some(user_key_id)) = (&data.encrypted_by_key_id, &user.key_id)
&& cipher_key_id != user_key_id
{
err!("Cipher was not encrypted with the current user key. Please try again.")
}
Ok(())
}
/// Upstream's `[StringLength(500000)]` on `CipherRequestModel.Data`
const MAX_CIPHER_DATA_LENGTH: usize = 500_000;
impl CipherData {
/// Whether `data` is a blob rather than the per-type fields. Parses `data`, so keep the result.
pub fn is_blob(&self) -> bool {
self.data.as_deref().is_some_and(is_data_blob_encrypted)
}
/// Saving's last checks: a known type, and its data unless it's a blob.
pub fn validate_type_data(&self, is_blob: bool) -> EmptyResult {
let type_data = match self.r#type {
1 => &self.login,
2 => &self.secure_note,
3 => &self.card,
4 => &self.identity,
5 => &self.ssh_key,
6 => &self.bank_account,
7 => &self.drivers_license,
8 => &self.passport,
_ => err!("Invalid type"),
};
if !is_blob && type_data.is_none() {
err!("Data missing")
}
Ok(())
}
/// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L76-L77>
/// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L91-L99>
pub fn validate_content(&self, is_blob: bool) -> Result<(), CipherValidationError> {
if let Some(data) = &self.data
&& data.len() > MAX_CIPHER_DATA_LENGTH
{
return Err(CipherValidationError {
field: "Data",
message: format!("The field Data must be a string with a maximum length of {MAX_CIPHER_DATA_LENGTH}."),
});
}
// A blob carries the name inside it, so only the other formats need one
if !is_blob && self.name.as_deref().is_none_or(|n| n.trim().is_empty()) {
return Err(CipherValidationError {
field: "Name",
message: String::from("The Name field is required."),
});
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PartialCipherData {
@@ -352,16 +457,14 @@ async fn post_ciphers_create(
) -> JsonResult {
let mut data: ShareCipherData = data.into_inner();
if data.cipher.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?;
// This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below.
enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?;
let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone());
let mut cipher = Cipher::new(data.cipher.r#type);
cipher.user_uuid = Some(headers.user.uuid.clone());
cipher.save(&conn).await?;
@@ -385,16 +488,7 @@ async fn post_ciphers_create(
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let mut data: CipherData = data.into_inner();
if data.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
if let Some(cipher_key_id) = &data.encrypted_by_key_id
&& let Some(user_key_id) = &headers.user.key_id
&& cipher_key_id != user_key_id
{
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
}
validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?;
// The web/browser clients set this field to null as expected, but the
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
@@ -402,7 +496,7 @@ async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt
// needed when creating a new cipher, so just ignore it unconditionally.
data.last_known_revision_date = None;
let mut cipher = Cipher::new(data.r#type, data.name.clone());
let mut cipher = Cipher::new(data.r#type);
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
@@ -426,6 +520,44 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: &
Ok(())
}
/// The checks of saving a cipher, for callers that run them before writing anything else
async fn validate_cipher_update(
cipher: &Cipher,
data: &CipherData,
headers: &Headers,
conn: &DbConn,
ut: UpdateType,
) -> EmptyResult {
// Check that the client isn't updating an existing cipher with stale data.
// And only perform this check when not importing ciphers, else the date/time check will fail.
if ut != UpdateType::None
&& let Some(dt) = &data.last_known_revision_date
{
match NaiveDateTime::parse_from_str(dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
Ok(_) => (),
}
}
if let Some(note) = &data.notes {
let max_note_size = CONFIG._max_note_size();
if note.len() > max_note_size {
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
}
}
if let Some(folder_id) = &data.folder_id
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
{
err!("Invalid folder", "Folder does not exist or belongs to another user");
}
Ok(())
}
pub async fn update_cipher_from_data(
cipher: &mut Cipher,
data: CipherData,
@@ -434,6 +566,40 @@ pub async fn update_cipher_from_data(
conn: &DbConn,
nt: &Notify<'_>,
ut: UpdateType,
) -> EmptyResult {
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
save_cipher_data(cipher, data, headers, shared_to_collections, conn, nt, ut).await
}
/// [`update_cipher_from_data`] for a rotation: like upstream, the personal ownership policy doesn't apply.
pub async fn rotate_cipher_data(
cipher: &mut Cipher,
data: CipherData,
headers: &Headers,
conn: &DbConn,
nt: &Notify<'_>,
) -> EmptyResult {
save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None).await
}
/// The checks of [`rotate_cipher_data`] that need the database, to run on every cipher before the first is saved
pub async fn validate_rotated_cipher(
cipher: &Cipher,
data: &CipherData,
headers: &Headers,
conn: &DbConn,
) -> EmptyResult {
validate_cipher_update(cipher, data, headers, conn, UpdateType::None).await
}
async fn save_cipher_data(
cipher: &mut Cipher,
data: CipherData,
headers: &Headers,
shared_to_collections: Option<Vec<CollectionId>>,
conn: &DbConn,
nt: &Notify<'_>,
ut: UpdateType,
) -> EmptyResult {
// Cleanup cipher data, like removing the 'Response' key.
// This key is somewhere generated during Javascript so no way for us this fix this.
@@ -449,34 +615,14 @@ pub async fn update_cipher_from_data(
json_data
}
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
// Check that the client isn't updating an existing cipher with stale data.
// And only perform this check when not importing ciphers, else the date/time check will fail.
if ut != UpdateType::None
&& let Some(dt) = data.last_known_revision_date
{
match NaiveDateTime::parse_from_str(&dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
Ok(_) => (),
}
}
let is_blob = data.is_blob();
data.validate_content(is_blob)?;
validate_cipher_update(cipher, &data, headers, conn, ut).await?;
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
err!("Organization mismatch. Please resync the client before updating the cipher")
}
if let Some(note) = &data.notes {
let max_note_size = CONFIG._max_note_size();
if note.len() > max_note_size {
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
}
}
// Check if this cipher is being transferred from a personal to an organization vault
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
@@ -507,12 +653,6 @@ pub async fn update_cipher_from_data(
cipher.user_uuid = Some(headers.user.uuid.clone());
}
if let Some(ref folder_id) = data.folder_id
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
{
err!("Invalid folder", "Folder does not exist or belongs to another user");
}
// Modify attachments name and keys when rotating
if let Some(attachments) = data.attachments2 {
for (id, attachment) in attachments {
@@ -551,26 +691,36 @@ pub async fn update_cipher_from_data(
_ => err!("Invalid type"),
};
let type_data = if let Some(mut data) = type_data_opt {
if let Some(blob) = data.data.filter(|_| is_blob) {
// A blob holds everything, the name included; the column can't be null, so it's left empty
// TODO: Make `ciphers.name` nullable and store `None` here instead.
cipher.name = String::new();
cipher.notes = None;
cipher.fields = None;
cipher.password_history = None;
cipher.data = blob;
} else {
let Some(mut type_data) = type_data_opt else {
err!("Data missing")
};
// Remove the 'Response' key from the base object.
if let Some(data_obj) = data.as_object_mut() {
if let Some(data_obj) = type_data.as_object_mut() {
data_obj.remove("response");
}
// Remove the 'Response' key from every Uri.
if data["uris"].is_array() {
data["uris"] = clean_cipher_data(data["uris"].clone());
if type_data["uris"].is_array() {
type_data["uris"] = clean_cipher_data(type_data["uris"].clone());
}
data
} else {
err!("Data missing")
};
// `validate_content` made sure there is a name
cipher.name = data.name.unwrap_or_default();
cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.data = type_data.to_string();
}
cipher.key = data.key;
cipher.name = data.name;
cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.data = type_data.to_string();
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
cipher.save(conn).await?;
@@ -667,7 +817,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
cipher_data.folder_id = folder_id;
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
}
@@ -735,6 +885,9 @@ async fn put_cipher(
err!("Cipher is not write accessible")
}
// Against the cipher we hold rather than the organization the client claims, like upstream
validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?;
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
@@ -993,9 +1146,7 @@ async fn post_cipher_share(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let data: ShareCipherData = data.into_inner();
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
put_cipher_share(cipher_id, data, headers, conn, nt).await
}
#[put("/ciphers/<cipher_id>/share", data = "<data>")]
@@ -1008,6 +1159,21 @@ async fn put_cipher_share(
) -> JsonResult {
let data: ShareCipherData = data.into_inner();
// Upstream's `PutShare` checks, before anything is written.
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L899-L912>
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L498-L519>
if data.cipher.organization_id.is_none() {
err!("Cipher OrganizationId is required.")
}
if data.collection_ids.is_empty() {
err!("You must select at least one collection.")
}
if !Cipher::find_by_uuid(&cipher_id, &conn).await.is_some_and(|c| c.user_uuid.as_ref() == Some(&headers.user.uuid))
{
err_code!("Cipher doesn't exist", Status::NotFound.code)
}
validate_encrypted_for_user(&data.cipher, &headers.user.uuid)?;
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
}
@@ -1027,18 +1193,35 @@ async fn put_cipher_share_selected(
) -> EmptyResult {
let mut data: ShareSelectedCipherData = data.into_inner();
// Upstream's `PutShareMany` checks, before anything is written.
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1395-L1421>
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L560-L604>
if data.ciphers.is_empty() {
err!("You must select at least one cipher.")
}
if data.ciphers.iter().any(|c| c.id.is_none() || c.organization_id.is_none()) {
err!("All Ciphers must have an Id and OrganizationId.")
}
if data.ciphers.iter().map(|c| &c.organization_id).collect::<HashSet<_>>().len() != 1 {
err!("All ciphers must be for the same organization.")
}
if data.collection_ids.is_empty() {
err!("You must select at least one collection.")
}
for cipher in &data.ciphers {
if cipher.id.is_none() {
err!("Request missing ids field")
}
let is_blob = cipher.is_blob();
cipher.validate_content(is_blob)?;
cipher.validate_type_data(is_blob)?;
}
for cipher in &data.ciphers {
validate_encrypted_for_user(cipher, &headers.user.uuid)?;
}
let owned_ciphers = Cipher::find_owned_by_user(&headers.user.uuid, &conn).await;
for cipher_data in &data.ciphers {
let Some(cipher) = owned_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) else {
err!("Trying to share ciphers that you do not own.")
};
validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?;
}
while let Some(cipher) = data.ciphers.pop() {
@@ -1085,6 +1268,22 @@ async fn share_cipher_by_uuid(
err!("Organization mismatch. Please resync the client before updating the cipher")
}
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
let ut = if let Some(ut) = override_ut {
ut
} else if data.cipher.last_known_revision_date.is_some() {
UpdateType::SyncCipherUpdate
} else {
UpdateType::SyncCipherCreate
};
// For the same reason, the other checks of saving
let is_blob = data.cipher.is_blob();
data.cipher.validate_content(is_blob)?;
data.cipher.validate_type_data(is_blob)?;
validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?;
let mut shared_to_collections = vec![];
if let Some(organization_id) = &data.cipher.organization_id {
@@ -1103,16 +1302,6 @@ async fn share_cipher_by_uuid(
}
}
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
let ut = if let Some(ut) = override_ut {
ut
} else if data.cipher.last_known_revision_date.is_some() {
UpdateType::SyncCipherUpdate
} else {
UpdateType::SyncCipherCreate
};
update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
+43 -6
View File
@@ -8,7 +8,7 @@ use crate::{
CONFIG,
api::admin::FAKE_ADMIN_UUID,
api::{
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
},
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
@@ -1831,7 +1831,7 @@ async fn post_org_import(
cipher_data.folder_id = None;
// Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data(
&mut cipher,
cipher_data,
@@ -2771,8 +2771,26 @@ struct OrganizationUserRecoverAccountRequest {
// But the clients do not seem to use this at all
// Just add it here in case they will
#[get("/organizations/<org_id>/public-key")]
async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.membership.org_uuid {
async fn get_organization_public_key(
org_id: OrganizationId,
headers: Headers,
member: Result<OrgMemberHeaders, &'static str>,
conn: DbConn,
) -> JsonResult {
// SSO users without an org get the fake one, whose key the v2 JIT password flow fetches anyway
if org_id.eq_ignore_ascii_case(FAKE_SSO_IDENTIFIER) && headers.user.private_key.is_none() {
return Ok(Json(json!({
"object": "organizationPublicKey",
"publicKey": fake_sso_org_public_key().await?,
})));
}
let member = match member {
Ok(member) => member,
// The guard already logged it
Err(e) => return Err(crate::error::Error::new_msg(e).with_code(Status::Unauthorized.code)),
};
if org_id != member.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
}
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
@@ -2785,11 +2803,30 @@ async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberH
})))
}
/// The SDK only uses this key to wrap an account recovery key that it never sends for the fake org,
/// so it is made once and its private half dropped.
async fn fake_sso_org_public_key() -> ApiResult<String> {
static PUBLIC_KEY: std::sync::LazyLock<Option<String>> = std::sync::LazyLock::new(|| {
let der = openssl::rsa::Rsa::generate(2048).and_then(|rsa| rsa.public_key_to_der()).ok()?;
Some(data_encoding::BASE64.encode(&der))
});
let Ok(Some(public_key)) = tokio::task::spawn_blocking(|| PUBLIC_KEY.clone()).await else {
err!("Failed to generate the organization key")
};
Ok(public_key)
}
// Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
#[get("/organizations/<org_id>/keys")]
async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
get_organization_public_key(org_id, headers, conn).await
async fn get_organization_keys(
org_id: OrganizationId,
headers: Headers,
member: Result<OrgMemberHeaders, &'static str>,
conn: DbConn,
) -> JsonResult {
get_organization_public_key(org_id, headers, member, conn).await
}
// Will allow to reset 2FA too
+4 -26
View File
@@ -545,24 +545,13 @@ async fn authenticated_response(
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey,
"Salt": user.email
"Salt": user.master_password_salt()
})
} else {
Value::Null
};
let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
let account_keys = user.account_keys_json(conn).await;
let mut result = json!({
"access_token": auth_tokens.access_token(),
@@ -702,24 +691,13 @@ async fn user_api_key_login(
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey,
"Salt": user.email
"Salt": user.master_password_salt()
})
} else {
Value::Null
};
let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
let account_keys = user.account_keys_json(conn).await;
// Note: No refresh_token is returned. The CLI just repeats the
// client_credentials login flow when the existing token expires.
+1 -1
View File
@@ -23,7 +23,7 @@ pub use crate::api::{
icons::routes as icons_routes,
identity::routes as identity_routes,
notifications::routes as notifications_routes,
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
push::{
push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device,
unregister_push_device,
+26 -6
View File
@@ -411,23 +411,35 @@ impl WebSocketUsers {
}
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
self.send_logout_with_reason(user, acting_device, None, conn).await;
}
/// A logout that clients may handle differently depending on `reason`
pub async fn send_logout_with_reason(
&self,
user: &User,
acting_device: Option<&Device>,
reason: Option<LogOutReason>,
conn: &DbConn,
) {
// Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED {
return;
}
let acting_device_id = acting_device.map(|d| d.uuid.clone());
let data = create_update(
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
UpdateType::LogOut,
acting_device_id,
);
let mut payload =
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))];
if let Some(reason) = reason {
payload.push(("Reason".into(), (reason as i32).into()));
}
let data = create_update(payload, UpdateType::LogOut, acting_device_id);
if CONFIG.enable_websocket() {
self.send_update(&user.uuid, &data).await;
}
if CONFIG.push_enabled() {
push_logout(user, acting_device, conn).await;
push_logout(user, acting_device, reason, conn).await;
}
}
@@ -695,6 +707,14 @@ fn create_ping() -> Vec<u8> {
serialize(&Value::Array(vec![6.into()]))
}
/// Why a logout was pushed, absent for a plain one. `KdfChange` isn't sent yet.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Enums/PushNotificationLogOutReason.cs#L3-L7>
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum LogOutReason {
KeyRotation = 1,
}
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum UpdateType {
+4 -3
View File
@@ -12,7 +12,7 @@ use tokio::sync::RwLock;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult, UpdateType},
api::{ApiResult, EmptyResult, LogOutReason, UpdateType},
db::{
DbConn,
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
@@ -188,7 +188,7 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
}
}
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Option<LogOutReason>, conn: &DbConn) {
if Device::check_user_has_push_device(&user.uuid, conn).await {
tokio::task::spawn(send_to_push_relay(json!({
"userId": user.uuid,
@@ -198,7 +198,8 @@ pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbC
"type": UpdateType::LogOut as i32,
"payload": {
"userId": user.uuid,
"date": format_date(&user.updated_at)
"date": format_date(&user.updated_at),
"reason": reason.map(|r| r as i32),
},
"clientType": null,
"installationId": null
+6 -25
View File
@@ -34,7 +34,7 @@ use crate::{
models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
SendFileId, SendId, User, UserId, UserStampException,
SendFileId, SendId, User, UserId,
},
},
error::Error,
@@ -685,31 +685,12 @@ impl<'r> FromRequest<'r> for Headers {
};
if user.security_stamp != claims.sstamp {
if let Some(stamp_exception) =
user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::<UserStampException>(s).ok())
{
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
err_handler!("Error getting current route for stamp exception")
};
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
err_handler!("Error getting current route for stamp exception")
};
// Check if the stamp exception has expired first.
// Then, check if the current route matches any of the allowed routes.
// After that check the stamp in exception matches the one in the claims.
if Utc::now().timestamp() > stamp_exception.expire {
// If the stamp exception has been expired remove it from the database.
// This prevents checking this stamp exception for new requests.
let mut user = user;
user.reset_stamp_exception();
if let Err(e) = user.save(&conn).await {
error!("Error updating user: {e:#?}");
}
err_handler!("Stamp exception is expired")
} else if !stamp_exception.routes.contains(&current_route.to_owned()) {
err_handler!("Invalid security stamp: Current route and exception route do not match")
} else if stamp_exception.security_stamp != claims.sstamp {
err_handler!("Invalid security stamp for matched stamp exception")
}
} else {
// Expired exceptions are only dropped on reset; writing them back here could undo a newer reset
if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) {
err_handler!("Invalid security stamp")
}
}
+8
View File
@@ -1430,8 +1430,16 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"undetermined-cipher-scenario-logic",
"enable-basic-auth-response",
"ssh-agent-v2",
"windows-desktop-autotype",
"windows-desktop-autotype-ga",
// Key Management Team
"biometrics-sdk-ipc",
"windows-native-credential-sync",
"pm-30144-sdk-key-rotation",
"pm-31050-no-logout-key-upgrade-rotation",
"enable-account-encryption-v2-jit-password-registration",
"pm-27278-v2-password-registration",
"force-upgrade-v2-encryption",
// Mobile Team
"pm-34171-card-scanner",
// Platform Team
+103 -61
View File
@@ -19,7 +19,7 @@ use crate::{
},
},
error::MapResult,
util::LowerCase,
util::{LowerCase, convert_json_key_lcase_first},
};
use macros::UuidFromParam;
@@ -63,6 +63,18 @@ pub struct Cipher {
pub reprompt: Option<i32>,
}
/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Vault/Entities/Cipher.cs#L36-L41>
pub fn is_data_blob_encrypted(data: &str) -> bool {
serde_json::from_str::<Value>(data).is_ok_and(|d| is_blob_value(&d))
}
/// [`is_data_blob_encrypted`] for `data` that was already parsed.
fn is_blob_value(data: &Value) -> bool {
data.get("format_version").is_some()
}
pub enum RepromptType {
None = 0,
Password = 1,
@@ -70,7 +82,8 @@ pub enum RepromptType {
/// Local methods
impl Cipher {
pub fn new(atype: i32, name: String) -> Self {
/// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
pub fn new(atype: i32) -> Self {
let now = Utc::now().naive_utc();
Self {
@@ -84,7 +97,7 @@ impl Cipher {
key: None,
atype,
name,
name: String::new(),
notes: None,
fields: None,
@@ -110,6 +123,11 @@ impl Cipher {
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
}
if let Err(e) = cipher.validate_content(cipher.is_blob()) {
validation_errors
.insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap());
}
// Validate the password history if it contains `null` values and if so, return a warning
if let Some(Value::Array(password_history)) = &cipher.password_history {
for pwh in password_history {
@@ -154,6 +172,10 @@ impl Cipher {
) -> Result<Value, crate::Error> {
use crate::util::{format_date, validate_and_format_date};
// Parsed once, since `data` can be large and this runs for every cipher in a sync
let type_data = serde_json::from_str::<Value>(&self.data);
let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value);
let mut attachments_json: Value = Value::Null;
if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
@@ -250,63 +272,9 @@ impl Cipher {
})
.unwrap_or_default();
// Get the type_data or a default to an empty json object '{}'.
// If not passing an empty object, mobile clients will crash.
let mut type_data_json = serde_json::from_str::<LowerCase<Value>>(&self.data)
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
.map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data);
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
// Like upstream, fields and password history stored as NULL are sent as null, not `[]`
let fields_json = self.fields.is_some().then_some(fields_json);
let password_history_json = self.password_history.is_some().then_some(password_history_json);
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
@@ -403,10 +371,84 @@ impl Cipher {
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
};
json_object[key] = type_data_json;
if is_blob_encrypted {
// Like upstream, sent as-is with the structured fields null
json_object["data"] = json!(self.data);
json_object["name"] = Value::Null;
} else {
json_object[key] = self.legacy_type_data_json(type_data);
}
Ok(json_object)
}
/// The per-type data of a legacy cipher, with fixups for values known to break clients.
fn legacy_type_data_json(&self, type_data: Result<Value, serde_json::Error>) -> Value {
use crate::util::validate_and_format_date;
// Get the type_data or a default to an empty json object '{}'.
// If not passing an empty object, mobile clients will crash.
let mut type_data_json = if let Ok(data @ Value::Object(_)) = type_data {
convert_json_key_lcase_first(data)
} else {
warn!("Error parsing data field for {}", self.uuid);
Value::Object(serde_json::Map::new())
};
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
type_data_json
}
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
let mut user_uuids = Vec::new();
match self.user_uuid {
+4 -2
View File
@@ -17,11 +17,12 @@ mod two_factor;
mod two_factor_duo_context;
mod two_factor_incomplete;
mod user;
mod user_signature_key_pair;
pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType};
pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
@@ -39,4 +40,5 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete;
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException};
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType};
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
+13 -7
View File
@@ -1,3 +1,4 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, From};
use diesel::prelude::*;
use serde::Deserialize;
@@ -11,6 +12,7 @@ use crate::{
schema::{org_policies, users_organizations},
},
error::MapResult,
util::format_date,
};
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
@@ -24,6 +26,7 @@ pub struct OrgPolicy {
pub atype: i32,
pub enabled: bool,
pub data: String,
pub revision_date: NaiveDateTime,
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs
@@ -46,7 +49,7 @@ pub enum OrgPolicyType {
RemoveUnlockWithPin = 14,
RestrictedItemTypes = 15,
UriMatchDefaults = 16,
// AutotypeDefaultSetting = 17, // Not supported yet
AutotypeDefaultSetting = 17,
// AutoConfirm = 18, // Not supported (not implemented yet)
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
OrganizationUserNotification = 20,
@@ -77,6 +80,7 @@ impl OrgPolicy {
atype: atype as i32,
enabled,
data,
revision_date: Utc::now().naive_utc(),
}
}
@@ -92,7 +96,7 @@ impl OrgPolicy {
"type": self.atype,
"data": data_json,
"enabled": self.enabled,
"revisionDate": null,
"revisionDate": format_date(&self.revision_date),
"object": "policy",
});
@@ -110,11 +114,13 @@ impl OrgPolicy {
/// Database methods
impl OrgPolicy {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.revision_date = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(org_policies::table)
.values(self)
.values(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
@@ -122,7 +128,7 @@ impl OrgPolicy {
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(org_policies::table)
.filter(org_policies::uuid.eq(&self.uuid))
.set(self)
.set(&*self)
.execute(conn)
.map_res("Error saving org_policy")
}
@@ -142,10 +148,10 @@ impl OrgPolicy {
.map_res("Error deleting org_policy for insert")?;
diesel::insert_into(org_policies::table)
.values(self)
.values(&*self)
.on_conflict(org_policies::uuid)
.do_update()
.set(self)
.set(&*self)
.execute(conn)
.map_res("Error saving org_policy")
}
+3
View File
@@ -54,6 +54,8 @@ pub struct Membership {
pub atype: i32,
pub reset_password_key: Option<String>,
pub external_id: Option<String>,
/// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us.
pub v2_upgrade_token: Option<String>,
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -271,6 +273,7 @@ impl Membership {
atype: MembershipType::User as i32,
reset_password_key: None,
external_id: None,
v2_upgrade_token: None,
}
}
+121 -13
View File
@@ -6,6 +6,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::EmptyResult,
auth::DEFAULT_ACCESS_VALIDITY,
crypto,
db::{
DbConn,
@@ -20,6 +21,7 @@ use macros::UuidFromParam;
use super::{
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
UserSignatureKeyPair,
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
@@ -71,6 +73,13 @@ pub struct User {
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
pub key_id: Option<KeyId>,
// The v2 state: all set, with a `user_signature_key_pairs` row, or none, see `User::is_v2`
pub signed_public_key: Option<String>,
pub security_state: Option<String>,
pub security_version: Option<i32>,
/// JSON `{"wrappedUserKey1", "wrappedUserKey2"}` for sessions that still hold the v1 user key. Opaque to us.
pub v2_upgrade_token: Option<String>,
}
#[derive(Identifiable, Queryable, Insertable)]
@@ -99,13 +108,28 @@ enum UserStatus {
_Disabled = 2,
}
/// A previous security stamp that is still accepted, until `expire`.
#[derive(Serialize, Deserialize)]
pub struct UserStampException {
pub routes: Vec<String>,
/// The routes the stamp is still accepted on, or `None` for any route.
pub routes: Option<Vec<String>>,
pub security_stamp: String,
pub expire: i64,
}
impl UserStampException {
pub fn is_expired(&self) -> bool {
Utc::now().timestamp() > self.expire
}
/// Whether this exception lets a token carrying `security_stamp` through on `route`.
pub fn allows(&self, security_stamp: &str, route: &str) -> bool {
!self.is_expired()
&& self.security_stamp == security_stamp
&& self.routes.as_ref().is_none_or(|routes| routes.iter().any(|r| r == route))
}
}
/// Local methods
impl User {
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
@@ -158,9 +182,18 @@ impl User {
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
key_id: None,
signed_public_key: None,
security_state: None,
security_version: None,
v2_upgrade_token: None,
}
}
pub fn is_v2(&self) -> bool {
self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some()
}
pub fn check_valid_password(&self, password: &str) -> bool {
crypto::verify_password_hash(
password.as_bytes(),
@@ -170,6 +203,7 @@ impl User {
)
}
/// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email
pub fn master_password_salt(&self) -> String {
self.email.trim().to_lowercase()
}
@@ -224,10 +258,33 @@ impl User {
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
self.security_stamp = get_uuid();
// Keeps the unexpired route exceptions, set for the stamp being replaced, and drops a rotation's grace
let mut exceptions = self.stamp_exceptions();
exceptions.retain(|e| e.routes.is_some() && !e.is_expired());
self.set_stamp_exceptions(&exceptions);
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
Ok(())
}
/// Resets the stamp but lets issued access tokens run out, as upstream only checks it on refresh.
pub async fn reset_security_stamp_after_key_rotation(&mut self, conn: &DbConn) -> EmptyResult {
let previous_stamp = self.security_stamp.clone();
let mut grace: Vec<UserStampException> =
self.stamp_exceptions().into_iter().filter(|e| e.routes.is_none() && !e.is_expired()).collect();
self.reset_security_stamp(conn).await?;
grace.push(UserStampException {
routes: None,
security_stamp: previous_stamp,
expire: (Utc::now() + *DEFAULT_ACCESS_VALIDITY).timestamp(),
});
let mut exceptions = self.stamp_exceptions();
exceptions.extend(grace);
self.set_stamp_exceptions(&exceptions);
Ok(())
}
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
///
/// # Arguments
@@ -236,17 +293,30 @@ impl User {
/// After these 2 minutes this stamp will expire.
///
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
let stamp_exception = UserStampException {
routes: route_exception,
self.set_stamp_exceptions(&[UserStampException {
routes: Some(route_exception),
security_stamp: self.security_stamp.clone(),
expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(),
};
self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default());
}]);
}
/// Resets the stamp_exception to prevent re-use of the previous security-stamp
pub fn reset_stamp_exception(&mut self) {
self.stamp_exception = None;
/// The previous security stamps that are still accepted, expired ones included.
pub fn stamp_exceptions(&self) -> Vec<UserStampException> {
let Some(stored) = self.stamp_exception.as_deref() else {
return Vec::new();
};
// Before key rotations kept sessions alive, only a single route exception was stored.
serde_json::from_str::<Vec<UserStampException>>(stored)
.or_else(|_| serde_json::from_str::<UserStampException>(stored).map(|e| vec![e]))
.unwrap_or_default()
}
fn set_stamp_exceptions(&mut self, exceptions: &[UserStampException]) {
self.stamp_exception = if exceptions.is_empty() {
None
} else {
Some(serde_json::to_string(&exceptions).unwrap_or_default())
};
}
pub fn display_name(&self) -> &str {
@@ -265,18 +335,37 @@ impl User {
!CONFIG.mail_enabled() || self.verified_at.is_some()
}
async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option<UserSignatureKeyPair> {
if !self.is_v2() {
return None;
}
UserSignatureKeyPair::find_by_user(&self.uuid, conn).await
}
/// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair
pub fn account_keys_json(&self) -> Value {
pub async fn account_keys_json(&self, conn: &DbConn) -> Value {
if self.private_key.is_some() {
let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (
json!(self.signed_public_key),
key_pair.to_json(),
json!({
"securityState": self.security_state,
"securityVersion": self.security_version,
}),
),
None => (Value::Null, Value::Null, Value::Null),
};
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key,
"publicKey": self.public_key,
"signedPublicKey": null,
"signedPublicKey": signed_public_key,
"object": "publicKeyEncryptionKeyPair",
},
"securityState": null,
"signatureKeyPair": null,
"securityState": security_state,
"signatureKeyPair": signature_key_pair,
"object": "privateKeys"
})
} else {
@@ -284,6 +373,24 @@ impl User {
}
}
pub async fn public_keys_json(&self, conn: &DbConn) -> Value {
let (signed_public_key, verifying_key) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (json!(self.signed_public_key), json!(key_pair.verifying_key)),
None => (Value::Null, Value::Null),
};
json!({
"publicKey": self.public_key,
"signedPublicKey": signed_public_key,
"verifyingKey": verifying_key,
"object": "publicKeys"
})
}
pub fn v2_upgrade_token_json(&self) -> Option<Value> {
self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok())
}
pub async fn to_json(&self, conn: &DbConn) -> Value {
let mut orgs_json = Vec::new();
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
@@ -304,7 +411,7 @@ impl User {
UserStatus::Enabled
};
let account_keys = self.account_keys_json();
let account_keys = self.account_keys_json(conn).await;
json!({
"_status": status as i32,
@@ -380,6 +487,7 @@ impl User {
Device::delete_all_by_user(&self.uuid, conn).await?;
TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
UserSignatureKeyPair::delete_all_by_user(&self.uuid, conn).await?;
Invitation::take(&self.email, conn).await; // Delete invitation if any
conn.run(move |conn| {
+132
View File
@@ -0,0 +1,132 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use serde_json::Value;
use crate::{
api::EmptyResult,
db::{DbConn, schema::user_signature_key_pairs},
error::MapResult,
util::get_uuid,
};
use super::UserId;
/// A user's signature key pair, part of the v2 state, in its own table like upstream.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Sql/dbo/KeyManagement/Tables/UserSignatureKeyPair.sql#L1-L15>
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
#[diesel(table_name = user_signature_key_pairs)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
pub struct UserSignatureKeyPair {
pub uuid: UserSignatureKeyPairId,
pub user_uuid: UserId,
pub signature_algorithm: i32,
/// The signing (private) key, wrapped by the user key.
pub signing_key: String,
/// The COSE-encoded public verifying key.
pub verifying_key: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Enums/SignatureAlgorithm.cs#L6-L10>
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SignatureAlgorithm {
Ed25519 = 0,
MlDsa44 = 1,
}
impl SignatureAlgorithm {
pub fn parse(algorithm: &str) -> Option<Self> {
match algorithm {
"ed25519" => Some(Self::Ed25519),
"mldsa44" => Some(Self::MlDsa44),
_ => None,
}
}
}
/// Local methods
impl UserSignatureKeyPair {
pub fn new(
user_uuid: UserId,
signature_algorithm: SignatureAlgorithm,
signing_key: String,
verifying_key: String,
) -> Self {
let now = Utc::now().naive_utc();
Self {
uuid: UserSignatureKeyPairId(get_uuid()),
user_uuid,
signature_algorithm: signature_algorithm as i32,
signing_key,
verifying_key,
created_at: now,
updated_at: now,
}
}
pub fn to_json(&self) -> Value {
json!({
"wrappedSigningKey": self.signing_key,
"verifyingKey": self.verifying_key,
"object": "signatureKeyPair",
})
}
}
/// Database methods
impl UserSignatureKeyPair {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
postgresql, sqlite {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(user_signature_key_pairs::user_uuid)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
}
}
/// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`.
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| {
user_signature_key_pairs::table
.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))
.first::<Self>(conn)
.ok()
})
.await
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting user signature key pairs")
})
.await
}
}
#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserSignatureKeyPairId(String);
+20
View File
@@ -116,6 +116,7 @@ table! {
atype -> Integer,
enabled -> Bool,
data -> Text,
revision_date -> Timestamp,
}
}
@@ -218,6 +219,22 @@ table! {
avatar_color -> Nullable<Text>,
external_id -> Nullable<Text>,
key_id -> Nullable<Text>,
signed_public_key -> Nullable<Text>,
security_state -> Nullable<Text>,
security_version -> Nullable<Integer>,
v2_upgrade_token -> Nullable<Text>,
}
}
table! {
user_signature_key_pairs (uuid) {
uuid -> Text,
user_uuid -> Text,
signature_algorithm -> Integer,
signing_key -> Text,
verifying_key -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
}
}
@@ -243,6 +260,7 @@ table! {
atype -> Integer,
reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>,
v2_upgrade_token -> Nullable<Text>,
}
}
@@ -383,6 +401,7 @@ joinable!(collections_groups -> groups (groups_uuid));
joinable!(event -> users_organizations (uuid));
joinable!(auth_requests -> users (user_uuid));
joinable!(sso_users -> users (user_uuid));
joinable!(user_signature_key_pairs -> users (user_uuid));
allow_tables_to_appear_in_same_query!(
archives,
@@ -409,4 +428,5 @@ allow_tables_to_appear_in_same_query!(
collections_groups,
event,
auth_requests,
user_signature_key_pairs,
);
+6
View File
@@ -839,6 +839,12 @@ pub fn parse_experimental_client_feature_flags(
.collect()
}
/// Whether the admin enabled this supported client feature flag
pub fn is_client_feature_flag_enabled(flag: &str) -> bool {
parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly)
.contains_key(flag)
}
/// TODO: This is extracted from IpAddr::is_global, which is unstable:
/// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global
/// Remove once https://github.com/rust-lang/rust/issues/27709 is merged