mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-14 16:16:04 +02:00
Compare commits
3
Commits
main
...
v2-followups
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4b2b64335 | ||
|
|
651ddbc2a1 | ||
|
|
59fb09544c |
No files matched your search
@@ -1244,10 +1244,11 @@ struct CommonUnlockData {
|
|||||||
v2_upgrade_token: Option<V2UpgradeTokenData>,
|
v2_upgrade_token: Option<V2UpgradeTokenData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us.
|
/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us. Stored on the user and
|
||||||
|
/// the memberships as this serializes, and read back with it (the account recovery V2 upgrade routes).
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct V2UpgradeTokenData {
|
pub(crate) struct V2UpgradeTokenData {
|
||||||
wrapped_user_key1: String,
|
wrapped_user_key1: String,
|
||||||
wrapped_user_key2: String,
|
wrapped_user_key2: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1565,15 +1565,16 @@ async fn get_attachment_data(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if scope == CipherAccessScope::OrganizationAdmin {
|
if scope == CipherAccessScope::OrganizationAdmin {
|
||||||
// NotFound as in Bitwarden, the web vault then falls back to the attachment's own URL
|
// NotFound as in Bitwarden. Its web vault then falls back to the attachment's URL from the listing, which isn't
|
||||||
|
// served here without a token, so a member who can open the item themselves gets the signed URL instead.
|
||||||
let Some(org_id) = cipher.organization_uuid.as_ref() else {
|
let Some(org_id) = cipher.organization_uuid.as_ref() else {
|
||||||
err_code!("Cipher is not owned by an organization", Status::NotFound.code)
|
err_code!("Cipher is not owned by an organization", Status::NotFound.code)
|
||||||
};
|
};
|
||||||
if !Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn)
|
let is_admin = Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn)
|
||||||
.await
|
.await
|
||||||
.is_some_and(|member| member.can_edit_any_collection())
|
.is_some_and(|member| member.can_edit_any_collection())
|
||||||
|| !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await
|
&& cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await;
|
||||||
{
|
if !is_admin && !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await {
|
||||||
err_code!("Cipher is not accessible", Status::NotFound.code)
|
err_code!("Cipher is not accessible", Status::NotFound.code)
|
||||||
}
|
}
|
||||||
} else if !cipher.is_accessible_to_user(&headers.user.uuid, scope, &conn).await {
|
} else if !cipher.is_accessible_to_user(&headers.user.uuid, scope, &conn).await {
|
||||||
@@ -1581,7 +1582,9 @@ async fn get_attachment_data(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match Attachment::find_by_id(&attachment_id, &conn).await {
|
match Attachment::find_by_id(&attachment_id, &conn).await {
|
||||||
Some(attachment) if cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host).await?)),
|
Some(attachment) if cipher_id == attachment.cipher_uuid => {
|
||||||
|
Ok(Json(attachment.to_json_for_download(&headers.host).await?))
|
||||||
|
}
|
||||||
Some(_) => err!("Attachment doesn't belong to cipher"),
|
Some(_) => err!("Attachment doesn't belong to cipher"),
|
||||||
None => err!("Attachment doesn't exist"),
|
None => err!("Attachment doesn't exist"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
|
Attachment, AttachmentId, Cipher, CipherId, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess,
|
||||||
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Membership, MembershipType,
|
||||||
|
OrgPolicy, TwoFactor, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -42,6 +43,7 @@ pub fn routes() -> Vec<Route> {
|
|||||||
takeover_emergency_access,
|
takeover_emergency_access,
|
||||||
password_emergency_access,
|
password_emergency_access,
|
||||||
view_emergency_access,
|
view_emergency_access,
|
||||||
|
get_emergency_access_attachment,
|
||||||
policies_emergency_access,
|
policies_emergency_access,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -616,6 +618,46 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, con
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An attachment of the grantor's for a grantee with approved view access, with a `url` to download it from: the view
|
||||||
|
/// lists the attachments with one that only names the file. Only the grantor's own ciphers, the ones the view lists.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Auth/Controllers/EmergencyAccessController.cs#L219-L226>
|
||||||
|
#[get("/emergency-access/<emer_id>/<cipher_id>/attachment/<attachment_id>")]
|
||||||
|
async fn get_emergency_access_attachment(
|
||||||
|
emer_id: EmergencyAccessId,
|
||||||
|
cipher_id: CipherId,
|
||||||
|
attachment_id: AttachmentId,
|
||||||
|
headers: Headers,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
|
let Some(emergency_access) =
|
||||||
|
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
|
else {
|
||||||
|
err!("Emergency access not valid.")
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_valid_request(&emergency_access, &headers.user.uuid, EmergencyAccessType::View) {
|
||||||
|
err!("Emergency access not valid.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
||||||
|
err!("Cipher doesn't exist")
|
||||||
|
};
|
||||||
|
if cipher.user_uuid.as_ref() != Some(&emergency_access.grantor_uuid) || cipher.organization_uuid.is_some() {
|
||||||
|
err!("Cipher is not accessible")
|
||||||
|
}
|
||||||
|
|
||||||
|
match Attachment::find_by_id(&attachment_id, &conn).await {
|
||||||
|
Some(attachment) if attachment.cipher_uuid == cipher_id => {
|
||||||
|
Ok(Json(attachment.to_json_for_download(&headers.host).await?))
|
||||||
|
}
|
||||||
|
Some(_) => err!("Attachment doesn't belong to cipher"),
|
||||||
|
None => err!("Attachment doesn't exist"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/emergency-access/<emer_id>/takeover")]
|
#[post("/emergency-access/<emer_id>/takeover")]
|
||||||
async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|||||||
+181
-22
@@ -21,7 +21,7 @@ use crate::{
|
|||||||
models::{
|
models::{
|
||||||
Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
|
Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
|
||||||
CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser,
|
CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser,
|
||||||
Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType,
|
Invitation, KeyId, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType,
|
||||||
Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId,
|
Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId,
|
||||||
custom_roles_enabled,
|
custom_roles_enabled,
|
||||||
},
|
},
|
||||||
@@ -31,7 +31,7 @@ use crate::{
|
|||||||
util::{NumberOrString, convert_json_key_lcase_first},
|
util::{NumberOrString, convert_json_key_lcase_first},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::accounts::{AuthenticationData, UnlockData};
|
use super::accounts::{AuthenticationData, UnlockData, V2UpgradeTokenData};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![
|
routes![
|
||||||
@@ -94,6 +94,9 @@ pub fn routes() -> Vec<Route> {
|
|||||||
get_group_members,
|
get_group_members,
|
||||||
put_reset_password_enrollment,
|
put_reset_password_enrollment,
|
||||||
get_reset_password_details,
|
get_reset_password_details,
|
||||||
|
get_pending_v2_upgrades,
|
||||||
|
post_v2_upgrades,
|
||||||
|
get_organization_private_key,
|
||||||
put_recover_account,
|
put_recover_account,
|
||||||
get_org_export,
|
get_org_export,
|
||||||
post_api_key,
|
post_api_key,
|
||||||
@@ -3188,12 +3191,7 @@ struct OrganizationUserRecoverAccountRequest {
|
|||||||
// But the clients do not seem to use this at all
|
// But the clients do not seem to use this at all
|
||||||
// Just add it here in case they will
|
// Just add it here in case they will
|
||||||
#[get("/organizations/<org_id>/public-key")]
|
#[get("/organizations/<org_id>/public-key")]
|
||||||
async fn get_organization_public_key(
|
async fn get_organization_public_key(org_id: OrganizationId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
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
|
// 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() {
|
if org_id.eq_ignore_ascii_case(FAKE_SSO_IDENTIFIER) && headers.user.private_key.is_none() {
|
||||||
return Ok(Json(json!({
|
return Ok(Json(json!({
|
||||||
@@ -3201,14 +3199,16 @@ async fn get_organization_public_key(
|
|||||||
"publicKey": fake_sso_org_public_key().await?,
|
"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 {
|
// Only for members, as `OrgMemberHeaders` checks it, but here, so that the fake org above neither logs a
|
||||||
err!("Organization not found", "Organization id's do not match");
|
// membership error nor authenticates the request twice: a known role, and not revoked (invited members are,
|
||||||
|
// while joining)
|
||||||
|
let is_member = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await.is_some_and(|member| {
|
||||||
|
MembershipType::from_i32(member.atype).is_some()
|
||||||
|
&& MembershipStatus::from_i32(member.status).is_some_and(|status| status != MembershipStatus::Revoked)
|
||||||
|
});
|
||||||
|
if !is_member {
|
||||||
|
err_code!("You need to be a Member of the Organization to call this endpoint", Status::Unauthorized.code)
|
||||||
}
|
}
|
||||||
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
||||||
err!("Organization not found")
|
err!("Organization not found")
|
||||||
@@ -3237,13 +3237,8 @@ async fn fake_sso_org_public_key() -> ApiResult<String> {
|
|||||||
// Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients
|
// 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
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
|
||||||
#[get("/organizations/<org_id>/keys")]
|
#[get("/organizations/<org_id>/keys")]
|
||||||
async fn get_organization_keys(
|
async fn get_organization_keys(org_id: OrganizationId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
org_id: OrganizationId,
|
get_organization_public_key(org_id, headers, conn).await
|
||||||
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
|
// Will allow to reset 2FA too
|
||||||
@@ -3364,6 +3359,167 @@ async fn recover_account(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(FromForm)]
|
||||||
|
struct PendingV2UpgradesQuery {
|
||||||
|
#[field(name = "continuationToken")]
|
||||||
|
continuation_token: Option<MembershipId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The memberships whose account recovery key an Owner or Admin still has to re-wrap with the member's V2 user key,
|
||||||
|
/// one page at a time: the client posts each page to `v2-upgrades` and reads the next one with the continuation
|
||||||
|
/// token, until it is null. An Admin gets every member but the Owners, whose key material only an Owner can read.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/KeyManagement/Controllers/OrganizationUsersKeysController.cs#L46-L83>
|
||||||
|
#[get("/organizations/<org_id>/users/keys/pending-v2-upgrades?<query..>")]
|
||||||
|
async fn get_pending_v2_upgrades(
|
||||||
|
org_id: OrganizationId,
|
||||||
|
query: PendingV2UpgradesQuery,
|
||||||
|
headers: AdminHeaders,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
|
if org_id != headers.org_id {
|
||||||
|
err!("Organization not found", "Organization id's do not match");
|
||||||
|
}
|
||||||
|
let include_owners = headers.membership_type == MembershipType::Owner;
|
||||||
|
let pending =
|
||||||
|
Membership::find_pending_v2_upgrades(&org_id, include_owners, query.continuation_token.as_ref(), &conn).await;
|
||||||
|
|
||||||
|
let continuation_token = if i64::try_from(pending.len()).is_ok_and(|len| len == Membership::MAX_V2_UPGRADES) {
|
||||||
|
pending.last().map(|upgrade| upgrade.member_id.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let data: Vec<Value> = pending
|
||||||
|
.into_iter()
|
||||||
|
.map(|upgrade| {
|
||||||
|
json!({
|
||||||
|
"object": "organizationUserPendingV2Upgrade",
|
||||||
|
"organizationUserId": upgrade.member_id,
|
||||||
|
"userKeyId": upgrade.user_key_id,
|
||||||
|
"accountRecoveryKey": upgrade.account_recovery_key,
|
||||||
|
// Null when the stored token can't be read: it holds no usable key, so the admin unenrolls the member
|
||||||
|
"v2UpgradeToken": serde_json::from_str::<V2UpgradeTokenData>(&upgrade.v2_upgrade_token).ok(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"object": "list",
|
||||||
|
"data": data,
|
||||||
|
"continuationToken": continuation_token,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct V2UpgradesData {
|
||||||
|
upgrades: Vec<V2UpgradeData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct V2UpgradeData {
|
||||||
|
organization_user_id: MembershipId,
|
||||||
|
/// The id of the user key the new account recovery key wraps, which must still be the member's
|
||||||
|
user_key_id: KeyId,
|
||||||
|
/// The member's V2 user key wrapped with the organization's public key, or none to unenroll the member (when the
|
||||||
|
/// upgrade token holds no usable key)
|
||||||
|
account_recovery_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the account recovery keys an Owner or Admin re-wrapped with members' V2 user keys, or unenrolls the members
|
||||||
|
/// whose entry has no key. Every member named must be one the caller can recover in this organization. A membership
|
||||||
|
/// that changed since the read (rotated again, unenrolled, upgraded already, promoted) is skipped and stays pending.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/KeyManagement/Controllers/OrganizationUsersKeysController.cs#L85-L108>
|
||||||
|
#[post("/organizations/<org_id>/users/keys/v2-upgrades", data = "<data>")]
|
||||||
|
async fn post_v2_upgrades(
|
||||||
|
org_id: OrganizationId,
|
||||||
|
data: Json<V2UpgradesData>,
|
||||||
|
headers: AdminHeaders,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> EmptyResult {
|
||||||
|
if org_id != headers.org_id {
|
||||||
|
err!("Organization not found", "Organization id's do not match");
|
||||||
|
}
|
||||||
|
let upgrades = data.into_inner().upgrades;
|
||||||
|
if upgrades.is_empty() || i64::try_from(upgrades.len()).map_or(true, |len| len > Membership::MAX_V2_UPGRADES) {
|
||||||
|
err!(format!("The field Upgrades must contain between 1 and {} entries.", Membership::MAX_V2_UPGRADES))
|
||||||
|
}
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
if !upgrades.iter().all(|upgrade| seen.insert(&upgrade.organization_user_id)) {
|
||||||
|
err!("An organization user can only be upgraded once per request.")
|
||||||
|
}
|
||||||
|
// A key or its absence: the token the key could be rebuilt from is consumed with the write, so a malformed key is
|
||||||
|
// refused rather than stored
|
||||||
|
if upgrades
|
||||||
|
.iter()
|
||||||
|
.filter_map(|upgrade| upgrade.account_recovery_key.as_deref())
|
||||||
|
.any(|key| !crate::util::is_valid_rsa_encrypted_string(key))
|
||||||
|
{
|
||||||
|
err!("The field AccountRecoveryKey is not a valid encrypted string.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let include_owners = headers.membership_type == MembershipType::Owner;
|
||||||
|
let mut members = Vec::with_capacity(upgrades.len());
|
||||||
|
for upgrade in &upgrades {
|
||||||
|
match Membership::find_by_uuid_and_org(&upgrade.organization_user_id, &org_id, &conn).await {
|
||||||
|
Some(member) if include_owners || member.atype != MembershipType::Owner => members.push(member),
|
||||||
|
_ => err_code!("Resource not found.", Status::NotFound.code),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every write in one transaction, as upstream's single statement, so a failure leaves the whole batch pending
|
||||||
|
let applied = conn
|
||||||
|
.transaction(async || {
|
||||||
|
let mut applied = Vec::with_capacity(members.len());
|
||||||
|
for (member, upgrade) in members.iter().zip(&upgrades) {
|
||||||
|
let key = upgrade.account_recovery_key.as_deref();
|
||||||
|
if member.apply_v2_upgrade(include_owners, &upgrade.user_key_id, key, &conn).await? {
|
||||||
|
applied.push((member, upgrade));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(applied)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (member, upgrade) in applied {
|
||||||
|
User::update_uuid_revision(&member.user_uuid, &conn).await;
|
||||||
|
// A re-wrapped key isn't logged: the member stays enrolled
|
||||||
|
if upgrade.account_recovery_key.is_none() {
|
||||||
|
log_event(
|
||||||
|
EventType::OrganizationUserResetPasswordWithdraw,
|
||||||
|
&upgrade.organization_user_id,
|
||||||
|
&org_id,
|
||||||
|
&headers.user.uuid,
|
||||||
|
headers.device.atype,
|
||||||
|
&headers.ip.ip,
|
||||||
|
&conn,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The organization's private key, wrapped with the organization key: the account recovery V2 upgrade (the SDK's
|
||||||
|
/// encrypted migrator) needs it to unwrap the members' V1 user keys. Open to everyone with Manage users, as upstream.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L473-L484>
|
||||||
|
#[get("/organizations/<org_id>/private-key")]
|
||||||
|
async fn get_organization_private_key(org_id: OrganizationId, headers: ManageUsersHeaders, conn: DbConn) -> JsonResult {
|
||||||
|
if org_id != headers.org_id {
|
||||||
|
err!("Organization not found", "Organization id's do not match");
|
||||||
|
}
|
||||||
|
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
||||||
|
err_code!("Resource not found.", Status::NotFound.code)
|
||||||
|
};
|
||||||
|
Ok(Json(json!({
|
||||||
|
"object": "organizationPrivateKey",
|
||||||
|
"privateKey": org.private_key,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/organizations/<org_id>/users/<member_id>/reset-password-details")]
|
#[get("/organizations/<org_id>/users/<member_id>/reset-password-details")]
|
||||||
async fn get_reset_password_details(
|
async fn get_reset_password_details(
|
||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
@@ -3476,6 +3632,9 @@ async fn put_reset_password_enrollment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
membership.reset_password_key = reset_password_key;
|
membership.reset_password_key = reset_password_key;
|
||||||
|
// A new key already wraps the current user key and a withdrawal leaves none. An upgrade token left from an earlier
|
||||||
|
// V2 upgrade would only get the member unenrolled by the account recovery V2 upgrade.
|
||||||
|
membership.v2_upgrade_token = None;
|
||||||
membership.save(&conn).await?;
|
membership.save(&conn).await?;
|
||||||
|
|
||||||
let event_type = if membership.reset_password_key.is_some() {
|
let event_type = if membership.reset_password_key.is_some() {
|
||||||
|
|||||||
@@ -65,16 +65,33 @@ impl Attachment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn to_json(&self, host: &str) -> Result<Value, crate::Error> {
|
/// The attachment as sync and the cipher responses list it. Its `url` is a leftover from the old download flow.
|
||||||
Ok(json!({
|
/// Clients get a signed URL from the attachment routes (`to_json_for_download`) instead, only the web vault and the
|
||||||
|
/// CLI read this one, as a fallback when those routes answer 404.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Vault/Models/Response/AttachmentResponseModel.cs#L24-L29>
|
||||||
|
pub fn to_json(&self, host: &str) -> Value {
|
||||||
|
self.json_with_url(&format!("{host}/attachments/{}/{}", self.cipher_uuid, self.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attachment with a `url` the client can download it from for a short while, for the attachment routes
|
||||||
|
/// (`GET /ciphers/{id}/attachment/{attachmentId}`, its admin variant, the emergency access one).
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Vault/Controllers/CiphersController.cs#L1699-L1717>
|
||||||
|
pub async fn to_json_for_download(&self, host: &str) -> Result<Value, crate::Error> {
|
||||||
|
Ok(self.json_with_url(&self.get_url(host).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_with_url(&self, url: &str) -> Value {
|
||||||
|
json!({
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"url": self.get_url(host).await?,
|
"url": url,
|
||||||
"fileName": self.file_name,
|
"fileName": self.file_name,
|
||||||
"size": self.file_size.to_string(),
|
"size": self.file_size.to_string(),
|
||||||
"sizeName": crate::util::get_display_size(self.file_size),
|
"sizeName": crate::util::get_display_size(self.file_size),
|
||||||
"key": self.akey,
|
"key": self.akey,
|
||||||
"object": "attachment"
|
"object": "attachment"
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ impl Cipher {
|
|||||||
{
|
{
|
||||||
let mut attachments_json_vec = vec![];
|
let mut attachments_json_vec = vec![];
|
||||||
for attachment in attachments {
|
for attachment in attachments {
|
||||||
attachments_json_vec.push(attachment.to_json(host).await?);
|
attachments_json_vec.push(attachment.to_json(host));
|
||||||
}
|
}
|
||||||
attachments_json = Value::Array(attachments_json_vec);
|
attachments_json = Value::Array(attachments_json_vec);
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ impl Cipher {
|
|||||||
if !attachments.is_empty() {
|
if !attachments.is_empty() {
|
||||||
let mut attachments_json_vec = vec![];
|
let mut attachments_json_vec = vec![];
|
||||||
for attachment in attachments {
|
for attachment in attachments {
|
||||||
attachments_json_vec.push(attachment.to_json(host).await?);
|
attachments_json_vec.push(attachment.to_json(host));
|
||||||
}
|
}
|
||||||
attachments_json = Value::Array(attachments_json_vec);
|
attachments_json = Value::Array(attachments_json_vec);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,27 @@ use crate::{
|
|||||||
organizations, users, users_collections, users_organizations,
|
organizations, users, users_collections, users_organizations,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::{Error, MapResult},
|
||||||
};
|
};
|
||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType,
|
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, KeyId, OrgPolicy,
|
||||||
TwoFactor, User, UserId,
|
OrgPolicyType, TwoFactor, User, UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// A membership whose account recovery key still wraps the member's V1 user key: an upgrade to V2 encryption can't
|
||||||
|
/// re-wrap it (that needs the member to trust the organization's key), so it left the token the admin re-wraps it from.
|
||||||
|
#[derive(Queryable)]
|
||||||
|
pub struct PendingV2Upgrade {
|
||||||
|
pub member_id: MembershipId,
|
||||||
|
/// The member's current user key id, which the re-wrapped key must be sent back with
|
||||||
|
pub user_key_id: KeyId,
|
||||||
|
/// The V1 user key, wrapped with the organization's public key
|
||||||
|
pub account_recovery_key: String,
|
||||||
|
pub v2_upgrade_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
#[diesel(table_name = organizations)]
|
#[diesel(table_name = organizations)]
|
||||||
#[diesel(treat_none_as_null = true)]
|
#[diesel(treat_none_as_null = true)]
|
||||||
@@ -291,7 +303,7 @@ impl CustomPermission {
|
|||||||
/// replaces the member's permissions, so a malformed value must not silently remove one. Granting a
|
/// replaces the member's permissions, so a malformed value must not silently remove one. Granting a
|
||||||
/// permission this version does not enforce is rejected as well. The clients also send permissions
|
/// permission this version does not enforce is rejected as well. The clients also send permissions
|
||||||
/// Vaultwarden does not know at all, like `manageSso`, those are ignored.
|
/// Vaultwarden does not know at all, like `manageSso`, those are ignored.
|
||||||
pub fn requested(permissions: &HashMap<String, Value>) -> Result<HashSet<CustomPermission>, crate::Error> {
|
pub fn requested(permissions: &HashMap<String, Value>) -> Result<HashSet<CustomPermission>, Error> {
|
||||||
let mut granted = HashSet::new();
|
let mut granted = HashSet::new();
|
||||||
for permission in CustomPermission::ALL {
|
for permission in CustomPermission::ALL {
|
||||||
match permissions.get(permission.json_key()) {
|
match permissions.get(permission.json_key()) {
|
||||||
@@ -1256,6 +1268,90 @@ impl Membership {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The most account recovery V2 upgrades one request reads or writes, like upstream's
|
||||||
|
/// `OrganizationUserV2UpgradesRequestModel.MaxUpgrades`.
|
||||||
|
pub const MAX_V2_UPGRADES: i64 = 100;
|
||||||
|
|
||||||
|
/// The next page of memberships of this organization whose account recovery key the admin still has to re-wrap
|
||||||
|
/// with the member's V2 user key: enrolled, carrying an upgrade token, of a user with a key id (the re-wrapped key
|
||||||
|
/// is checked against it), and Owners only for an Owner. Ordered by id, after `after`, at most `MAX_V2_UPGRADES`.
|
||||||
|
pub async fn find_pending_v2_upgrades(
|
||||||
|
org_uuid: &OrganizationId,
|
||||||
|
include_owners: bool,
|
||||||
|
after: Option<&MembershipId>,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> Vec<PendingV2Upgrade> {
|
||||||
|
conn.run(move |conn| {
|
||||||
|
let mut query = users_organizations::table
|
||||||
|
.inner_join(users::table)
|
||||||
|
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||||
|
.filter(users_organizations::v2_upgrade_token.is_not_null())
|
||||||
|
.filter(users_organizations::reset_password_key.is_not_null())
|
||||||
|
.filter(users::key_id.is_not_null())
|
||||||
|
.into_boxed();
|
||||||
|
if !include_owners {
|
||||||
|
query = query.filter(users_organizations::atype.ne(MembershipType::Owner as i32));
|
||||||
|
}
|
||||||
|
if let Some(after) = after {
|
||||||
|
query = query.filter(users_organizations::uuid.gt(after));
|
||||||
|
}
|
||||||
|
query
|
||||||
|
.order(users_organizations::uuid)
|
||||||
|
.limit(Self::MAX_V2_UPGRADES)
|
||||||
|
// The filters above make sure these are set
|
||||||
|
.select((
|
||||||
|
users_organizations::uuid,
|
||||||
|
users::key_id.assume_not_null(),
|
||||||
|
users_organizations::reset_password_key.assume_not_null(),
|
||||||
|
users_organizations::v2_upgrade_token.assume_not_null(),
|
||||||
|
))
|
||||||
|
.load(conn)
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the account recovery key of a member who upgraded to V2 encryption with `account_recovery_key`, or
|
||||||
|
/// unenrolls them when it is `None`, and consumes the upgrade token, so it can't be replayed. Only while the
|
||||||
|
/// membership is still enrolled, carrying the token, not an Owner unless `include_owners`, and its user's key id is
|
||||||
|
/// still `user_key_id` (the key the new recovery key wraps). Returns whether it applied, otherwise the membership
|
||||||
|
/// stays pending. It runs inside a transaction, so it leaves the user's revision to the caller.
|
||||||
|
pub async fn apply_v2_upgrade(
|
||||||
|
&self,
|
||||||
|
include_owners: bool,
|
||||||
|
user_key_id: &KeyId,
|
||||||
|
account_recovery_key: Option<&str>,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
// An empty list excludes no one
|
||||||
|
let excluded = if include_owners {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
vec![MembershipType::Owner as i32]
|
||||||
|
};
|
||||||
|
let rows: usize = conn
|
||||||
|
.run(move |conn| {
|
||||||
|
diesel::update(
|
||||||
|
users_organizations::table
|
||||||
|
.filter(users_organizations::uuid.eq(&self.uuid))
|
||||||
|
.filter(users_organizations::v2_upgrade_token.is_not_null())
|
||||||
|
.filter(users_organizations::reset_password_key.is_not_null())
|
||||||
|
.filter(users_organizations::atype.ne_all(excluded))
|
||||||
|
.filter(diesel::dsl::exists(
|
||||||
|
users::table.filter(users::uuid.eq(&self.user_uuid)).filter(users::key_id.eq(user_key_id)),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.set((
|
||||||
|
users_organizations::reset_password_key.eq(account_recovery_key),
|
||||||
|
users_organizations::v2_upgrade_token.eq(None::<String>),
|
||||||
|
))
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving membership")
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(rows > 0)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn count_confirmed_by_org_and_type(
|
pub async fn count_confirmed_by_org_and_type(
|
||||||
org_uuid: &OrganizationId,
|
org_uuid: &OrganizationId,
|
||||||
atype: MembershipType,
|
atype: MembershipType,
|
||||||
|
|||||||
+36
@@ -995,6 +995,22 @@ pub async fn save_temp_file(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `value` has the form of an RSA-encrypted string (types 3 to 6), the form of the account recovery key: the
|
||||||
|
/// type and its number of `|` pieces like the clients' `UnsignedSharedKey` parser, and each piece canonical, padded
|
||||||
|
/// base64, which is what the clients send.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/sdk-internal/blob/6238472451e4fa7b5c592b4b24a84e950a258957/crates/bitwarden-crypto/src/enc_string/asymmetric.rs#L85-L119>
|
||||||
|
pub fn is_valid_rsa_encrypted_string(value: &str) -> bool {
|
||||||
|
let (pieces, rest) = match value.split_once('.') {
|
||||||
|
Some(("3" | "4", rest)) => (1, rest),
|
||||||
|
Some(("5" | "6", rest)) => (2, rest),
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
let parts: Vec<&str> = rest.split('|').collect();
|
||||||
|
parts.len() == pieces
|
||||||
|
&& parts.iter().all(|part| !part.is_empty() && data_encoding::BASE64.decode(part.as_bytes()).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
/// These are some tests to check that the implementations match
|
/// These are some tests to check that the implementations match
|
||||||
/// The IPv4 can be all checked in 30 seconds or so and they are correct as of nightly 2023-07-17
|
/// The IPv4 can be all checked in 30 seconds or so and they are correct as of nightly 2023-07-17
|
||||||
/// The IPV6 can't be checked in a reasonable time, so we check over a hundred billion random ones, so far correct
|
/// The IPV6 can't be checked in a reasonable time, so we check over a hundred billion random ones, so far correct
|
||||||
@@ -1073,3 +1089,23 @@ mod query_log_tests {
|
|||||||
assert_eq!(&query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)], format!("{name}="));
|
assert_eq!(&query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)], format!("{name}="));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod encrypted_string_tests {
|
||||||
|
use super::is_valid_rsa_encrypted_string as valid;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rsa_encrypted_strings_are_validated() {
|
||||||
|
assert!(valid("3.YWJjZA=="));
|
||||||
|
assert!(valid("4.YWJjZA=="));
|
||||||
|
assert!(valid("6.YWJjZA==|YWJjZA=="));
|
||||||
|
assert!(!valid("2.YWJjZA==|YWJjZA==|YWJjZA=="), "symmetric type");
|
||||||
|
assert!(!valid("YWJjZA=="), "no type");
|
||||||
|
assert!(!valid("4.not base64!"));
|
||||||
|
assert!(!valid("4.YWJjZA==|YWJjZA=="), "one piece too many");
|
||||||
|
assert!(!valid("5.YWJjZA=="), "one piece too few");
|
||||||
|
assert!(!valid("4.YWJjZB=="), "padding bits set");
|
||||||
|
assert!(!valid("4."), "empty piece");
|
||||||
|
assert!(!valid(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user