mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-10-26 14:46:47 +02:00
Implement V2 key rotation support
This commit is contained in:
11 files changed
+891
-231
No files matched your search
+148
-117
@@ -63,6 +63,17 @@ pub struct Cipher {
|
||||
pub reprompt: Option<i32>,
|
||||
}
|
||||
|
||||
/// Whether `data` is a sealed cipher blob rather than the legacy per-type JSON.
|
||||
///
|
||||
/// Ciphers of v2 accounts are encrypted as a single blob that carries everything, name included,
|
||||
/// and is opaque to us. It is recognized the same way upstream does, by a top-level
|
||||
/// `format_version` key that the legacy JSON never has.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Core/Vault/Entities/Cipher.cs>
|
||||
pub fn is_data_blob_encrypted(data: &str) -> bool {
|
||||
serde_json::from_str::<Value>(data).is_ok_and(|d| d.get("format_version").is_some())
|
||||
}
|
||||
|
||||
pub enum RepromptType {
|
||||
None = 0,
|
||||
Password = 1,
|
||||
@@ -110,6 +121,10 @@ impl Cipher {
|
||||
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
|
||||
}
|
||||
|
||||
if let Err((field, message)) = cipher.validate_content() {
|
||||
validation_errors.insert(format!("Ciphers[{index}].{field}"), serde_json::to_value([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 +169,8 @@ impl Cipher {
|
||||
) -> Result<Value, crate::Error> {
|
||||
use crate::util::{format_date, validate_and_format_date};
|
||||
|
||||
let is_blob_encrypted = is_data_blob_encrypted(&self.data);
|
||||
|
||||
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)
|
||||
@@ -189,124 +206,62 @@ impl Cipher {
|
||||
(false, false, false)
|
||||
};
|
||||
|
||||
let fields_json: Vec<_> = self
|
||||
.fields
|
||||
.as_ref()
|
||||
.and_then(|s| {
|
||||
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
|
||||
.inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid))
|
||||
.ok()
|
||||
})
|
||||
.map(|d| {
|
||||
d.into_iter()
|
||||
.map(|mut f| {
|
||||
// Check if the `type` key is a number, strings break some clients
|
||||
// The fallback type is the hidden type `1`. this should prevent accidental data disclosure
|
||||
// If not try to convert the string value to a number and fallback to `1`
|
||||
// If it is both not a number and not a string, fallback to `1`
|
||||
match f.data.get("type") {
|
||||
Some(t) if t.is_number() => {}
|
||||
Some(t) if t.is_string() => {
|
||||
let type_num = &t.as_str().unwrap_or("1").parse::<u8>().unwrap_or(1);
|
||||
f.data["type"] = json!(type_num);
|
||||
// Like upstream, a cipher that was stored without fields or password history reports them as
|
||||
// null rather than as an empty list; clients keep the two apart.
|
||||
let fields_json: Option<Vec<_>> = self.fields.as_ref().map(|s| {
|
||||
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
|
||||
.inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid))
|
||||
.ok()
|
||||
.map(|d| {
|
||||
d.into_iter()
|
||||
.map(|mut f| {
|
||||
// Check if the `type` key is a number, strings break some clients
|
||||
// The fallback type is the hidden type `1`. this should prevent accidental data disclosure
|
||||
// If not try to convert the string value to a number and fallback to `1`
|
||||
// If it is both not a number and not a string, fallback to `1`
|
||||
match f.data.get("type") {
|
||||
Some(t) if t.is_number() => {}
|
||||
Some(t) if t.is_string() => {
|
||||
let type_num = &t.as_str().unwrap_or("1").parse::<u8>().unwrap_or(1);
|
||||
f.data["type"] = json!(type_num);
|
||||
}
|
||||
_ => {
|
||||
f.data["type"] = json!(1);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
f.data["type"] = json!(1);
|
||||
}
|
||||
}
|
||||
f.data
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
f.data
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let password_history_json: Vec<_> = self
|
||||
.password_history
|
||||
.as_ref()
|
||||
.and_then(|s| {
|
||||
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
|
||||
.inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid))
|
||||
.ok()
|
||||
})
|
||||
.map(|d| {
|
||||
// Check every password history item if they are valid and return it.
|
||||
// If a password field has the type `null` skip it, it breaks newer Bitwarden clients
|
||||
// A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used
|
||||
d.into_iter()
|
||||
.filter_map(|d| match d.data.get("password") {
|
||||
Some(p) if p.is_string() => Some(d.data),
|
||||
_ => None,
|
||||
})
|
||||
.map(|mut d| {
|
||||
let lud = if let Some(l) = d.get("lastUsedDate").and_then(|l| l.as_str()) {
|
||||
validate_and_format_date(l)
|
||||
} else {
|
||||
"1970-01-01T00:00:00.000000Z".to_owned()
|
||||
};
|
||||
d["lastUsedDate"] = json!(lud);
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.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;
|
||||
}
|
||||
let password_history_json: Option<Vec<_>> = self.password_history.as_ref().map(|s| {
|
||||
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
|
||||
.inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid))
|
||||
.ok()
|
||||
.map(|d| {
|
||||
// Check every password history item if they are valid and return it.
|
||||
// If a password field has the type `null` skip it, it breaks newer Bitwarden clients
|
||||
// A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used
|
||||
d.into_iter()
|
||||
.filter_map(|d| match d.data.get("password") {
|
||||
Some(p) if p.is_string() => Some(d.data),
|
||||
_ => None,
|
||||
})
|
||||
.map(|mut d| {
|
||||
let lud = if let Some(l) = d.get("lastUsedDate").and_then(|l| l.as_str()) {
|
||||
validate_and_format_date(l)
|
||||
} else {
|
||||
"1970-01-01T00:00:00.000000Z".to_owned()
|
||||
};
|
||||
d["lastUsedDate"] = json!(lud);
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
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 +358,86 @@ impl Cipher {
|
||||
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
|
||||
};
|
||||
|
||||
json_object[key] = type_data_json;
|
||||
if is_blob_encrypted {
|
||||
// The blob holds all of the content, so it is sent back as-is and the structured fields
|
||||
// stay null, as upstream does.
|
||||
json_object["data"] = json!(self.data);
|
||||
json_object["name"] = Value::Null;
|
||||
json_object["notes"] = Value::Null;
|
||||
json_object["fields"] = Value::Null;
|
||||
json_object["passwordHistory"] = Value::Null;
|
||||
} else {
|
||||
json_object[key] = self.legacy_type_data_json();
|
||||
}
|
||||
Ok(json_object)
|
||||
}
|
||||
|
||||
/// The per-type data (`login`, `card`, …) of a legacy cipher, with fixups for values that are
|
||||
/// known to break clients.
|
||||
fn legacy_type_data_json(&self) -> 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 = 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;
|
||||
}
|
||||
|
||||
type_data_json
|
||||
}
|
||||
|
||||
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
|
||||
let mut user_uuids = Vec::new();
|
||||
match self.user_uuid {
|
||||
|
||||
@@ -22,7 +22,7 @@ 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};
|
||||
@@ -40,5 +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};
|
||||
+93
-10
@@ -6,6 +6,7 @@ use serde_json::Value;
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::EmptyResult,
|
||||
auth::DEFAULT_ACCESS_VALIDITY,
|
||||
crypto,
|
||||
db::{
|
||||
DbConn,
|
||||
@@ -109,13 +110,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;
|
||||
@@ -239,10 +255,38 @@ impl User {
|
||||
|
||||
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
|
||||
self.security_stamp = get_uuid();
|
||||
// A reset is meant to end the other sessions, which a key rotation's grace would undo. The
|
||||
// route exceptions stay, since they are set right before a reset, for the stamp it replaces.
|
||||
self.retain_stamp_exceptions(|e| e.routes.is_some());
|
||||
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resets the security stamp after a key rotation, while letting the access tokens already
|
||||
/// issued keep working until they expire.
|
||||
///
|
||||
/// This is what upstream does: there the stamp is only checked when a token is refreshed, so a
|
||||
/// rotation invalidates refresh tokens but not the access tokens in use. Clients rely on it to
|
||||
/// keep a session going across a rotation. Chained rotations keep every earlier stamp that is
|
||||
/// still within its window.
|
||||
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
|
||||
@@ -251,17 +295,56 @@ 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())
|
||||
};
|
||||
}
|
||||
|
||||
/// Persists only `stamp_exception`, for callers holding a user read earlier in the request.
|
||||
///
|
||||
/// A full `save` would write back every other column as it was read, undoing anything saved in
|
||||
/// the meantime, such as the new keys of a key rotation made from another device.
|
||||
pub async fn save_stamp_exceptions(&self, conn: &DbConn) -> EmptyResult {
|
||||
let uuid = self.uuid.clone();
|
||||
let stamp_exception = self.stamp_exception.clone();
|
||||
conn.run(move |conn| {
|
||||
diesel::update(users::table.filter(users::uuid.eq(uuid)))
|
||||
.set(users::stamp_exception.eq(stamp_exception))
|
||||
.execute(conn)
|
||||
.map_res("Error updating user stamp exceptions")
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Drops the stamp exceptions that don't match `keep`. Returns whether any were dropped.
|
||||
pub fn retain_stamp_exceptions(&mut self, keep: impl Fn(&UserStampException) -> bool) -> bool {
|
||||
let mut exceptions = self.stamp_exceptions();
|
||||
let before = exceptions.len();
|
||||
exceptions.retain(keep);
|
||||
let changed = exceptions.len() != before;
|
||||
self.set_stamp_exceptions(&exceptions);
|
||||
changed
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &str {
|
||||
@@ -327,8 +410,8 @@ impl User {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn v2_upgrade_token_json(&self) -> Value {
|
||||
self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok()).unwrap_or(Value::Null)
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user